berkbirkan commited on
Commit
e9ec992
·
0 Parent(s):

Weather Tool Calling Agent

Browse files
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Weather Tool Calling Agent
3
+ emoji: ⛅
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 5.49.1
8
+ app_file: app.py
9
+ pinned: false
10
+ license: mit
11
+ ---
12
+
13
+ # Weather Tool Calling Agent
14
+
15
+ Bu Space, bir dil modelinin kullanıcı sorusuna göre araç seçmesini, açık bir API'den
16
+ veri almasını ve araç sonuçlarından nihai yanıt üretmesini gösterir.
17
+
18
+ ## Akış
19
+
20
+ 1. Model şehir başına `get_weather` çağırır.
21
+ 2. Fonksiyon, Open-Meteo Geocoding ve Forecast API'lerinden güncel veriyi alır.
22
+ 3. Kullanıcı Fahrenheit isterse model her sıcaklık için `convert_temperature` çağırır.
23
+ 4. Arayüz model turlarını, argümanları ve araç çıktılarını açıkça gösterir.
24
+ 5. Model, araç sonuçlarına dayanarak Türkçe nihai yanıtı üretir.
25
+
26
+ ## Tool JSON şemaları
27
+
28
+ Uygulamadaki `TOOLS` sabiti iki function definition içerir:
29
+
30
+ - `get_weather(city: string)`
31
+ - `convert_temperature(value: number, from_unit: "C"|"F", to_unit: "C"|"F")`
32
+
33
+ Her ikisi de strict JSON Schema kullanır.
34
+
35
+ ## Teknolojiler
36
+
37
+ - Gradio
38
+ - OpenAI Responses API
39
+ - OpenRouter Chat Completions API
40
+ - Open-Meteo (ücretsiz, API anahtarı gerektirmez)
41
+ - `httpx`
42
+
43
+ ## Hugging Face Spaces kurulumu
44
+
45
+ 1. Yeni bir **Gradio Space** açın.
46
+ 2. Bu klasördeki `app.py`, `requirements.txt` ve `README.md` dosyalarını Space'e yükleyin.
47
+ 3. Space → **Settings → Variables and secrets → New secret** bölümünde:
48
+ - Ad: `OPENAI_API_KEY`
49
+ - Değer: OpenAI API anahtarınız
50
+ 4. OpenRouter'ı Space anahtarıyla sunmak isterseniz `OPENROUTER_API_KEY` Secret'ını ekleyin.
51
+ 5. İsteğe bağlı variable: `OPENAI_MODEL=gpt-5.6-sol`
52
+ 6. Space yeniden başladıktan sonra örnek sorguyla test edin.
53
+
54
+ API anahtarları Secret olarak tanımlanmazsa kullanıcı arayüzde kendi OpenAI veya
55
+ OpenRouter anahtarını geçici olarak girebilir. Anahtar loglanmaz veya diske yazılmaz.
56
+
57
+ OpenRouter model listesi canlı katalogdan alınır ve yalnızca `tools` desteğini ilan
58
+ eden modeller gösterilir.
59
+
60
+ ## Yerelde çalıştırma
61
+
62
+ ```bash
63
+ pip install -r requirements.txt
64
+ export OPENAI_API_KEY="..."
65
+ python app.py
66
+ ```
67
+
68
+ ## Örnek
69
+
70
+ > Ankara mı daha sıcak Londra mı, ve bu değerler Fahrenheit olarak kaç eder?
71
+
72
+ İz panelinde önce iki `get_weather`, ardından gerekli `convert_temperature` çağrıları
73
+ görünür; son panelde karşılaştırmalı yanıt yer alır.
74
+
75
+ ## Veri ve güvenilirlik notu
76
+
77
+ Hava durumu Open-Meteo'dan çalışma anında alınır. Şehir bulunamazsa veya dış servis
78
+ erişilemezse uygulama veri uydurmak yerine hata bilgisini modele geri verir.
__pycache__/app.cpython-314.pyc ADDED
Binary file (16.1 kB). View file
 
__pycache__/llm_providers.cpython-310.pyc ADDED
Binary file (3.99 kB). View file
 
__pycache__/llm_providers.cpython-314.pyc ADDED
Binary file (7.33 kB). View file
 
app.py ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ from typing import Any
4
+
5
+ import gradio as gr
6
+ import httpx
7
+ from openai import OpenAI
8
+
9
+ from llm_providers import (
10
+ OPENAI_DEFAULT,
11
+ OPENROUTER_DEFAULT,
12
+ provider_models,
13
+ run_openrouter_tool_loop,
14
+ )
15
+
16
+ MODEL = os.getenv("OPENAI_MODEL", OPENAI_DEFAULT)
17
+ TIMEOUT = 15.0
18
+ MAX_TURNS = 6
19
+
20
+ WEATHER_CODES = {
21
+ 0: "açık",
22
+ 1: "çoğunlukla açık",
23
+ 2: "parçalı bulutlu",
24
+ 3: "kapalı",
25
+ 45: "sisli",
26
+ 48: "kırağılı sis",
27
+ 51: "hafif çisenti",
28
+ 53: "çisenti",
29
+ 55: "yoğun çisenti",
30
+ 61: "hafif yağmurlu",
31
+ 63: "yağmurlu",
32
+ 65: "şiddetli yağmurlu",
33
+ 71: "hafif kar yağışlı",
34
+ 73: "kar yağışlı",
35
+ 75: "yoğun kar yağışlı",
36
+ 80: "hafif sağanak",
37
+ 81: "sağanak",
38
+ 82: "şiddetli sağanak",
39
+ 95: "gök gürültülü fırtına",
40
+ }
41
+
42
+ TOOLS = [
43
+ {
44
+ "type": "function",
45
+ "name": "get_weather",
46
+ "description": (
47
+ "Bir şehir adını koordinata çevirir ve Open-Meteo'dan o konumdaki "
48
+ "güncel sıcaklık, hissedilen sıcaklık, hava durumu ve rüzgârı getirir. "
49
+ "Başarılı sonuç city, country, temp_c, apparent_temp_c, sky ve wind_kmh alanlarını içerir."
50
+ ),
51
+ "parameters": {
52
+ "type": "object",
53
+ "properties": {
54
+ "city": {
55
+ "type": "string",
56
+ "description": "Hava durumu istenen şehir; ör. Ankara veya London",
57
+ }
58
+ },
59
+ "required": ["city"],
60
+ "additionalProperties": False,
61
+ },
62
+ "strict": True,
63
+ },
64
+ {
65
+ "type": "function",
66
+ "name": "convert_temperature",
67
+ "description": (
68
+ "Sıcaklığı Celsius veya Fahrenheit birimine dönüştürür. "
69
+ "Sonuç value, unit, source_value ve source_unit alanlarını içerir."
70
+ ),
71
+ "parameters": {
72
+ "type": "object",
73
+ "properties": {
74
+ "value": {"type": "number", "description": "Dönüştürülecek sıcaklık"},
75
+ "from_unit": {
76
+ "type": "string",
77
+ "enum": ["C", "F"],
78
+ "description": "Kaynak birim",
79
+ },
80
+ "to_unit": {
81
+ "type": "string",
82
+ "enum": ["C", "F"],
83
+ "description": "Hedef birim",
84
+ },
85
+ },
86
+ "required": ["value", "from_unit", "to_unit"],
87
+ "additionalProperties": False,
88
+ },
89
+ "strict": True,
90
+ },
91
+ ]
92
+
93
+ SYSTEM_PROMPT = """Sen Türkçe yanıt veren bir hava durumu asistanısın.
94
+ Güncel hava durumu sorularında mutlaka get_weather aracını kullan.
95
+ Kullanıcı sıcaklığı farklı bir birimde isterse hesaplamayı kendin yapma; her değer için
96
+ convert_temperature aracını kullan. Birden fazla şehir için bağımsız çağrıları aynı turda
97
+ yapabilirsin. Araç sonucu hata içerirse bunu açıkça belirt ve veri uydurma.
98
+ Son yanıtı kısa, karşılaştırmalı ve kullanılan birimleri açık yazarak ver."""
99
+
100
+
101
+ def get_weather(city: str) -> dict[str, Any]:
102
+ with httpx.Client(timeout=TIMEOUT, follow_redirects=True) as client:
103
+ geo_response = client.get(
104
+ "https://geocoding-api.open-meteo.com/v1/search",
105
+ params={"name": city, "count": 1, "language": "tr", "format": "json"},
106
+ )
107
+ geo_response.raise_for_status()
108
+ matches = geo_response.json().get("results") or []
109
+ if not matches:
110
+ return {"error": f"'{city}' için konum bulunamadı."}
111
+
112
+ place = matches[0]
113
+ weather_response = client.get(
114
+ "https://api.open-meteo.com/v1/forecast",
115
+ params={
116
+ "latitude": place["latitude"],
117
+ "longitude": place["longitude"],
118
+ "current": "temperature_2m,apparent_temperature,weather_code,wind_speed_10m",
119
+ "timezone": "auto",
120
+ },
121
+ )
122
+ weather_response.raise_for_status()
123
+ current = weather_response.json()["current"]
124
+
125
+ code = int(current["weather_code"])
126
+ return {
127
+ "city": place["name"],
128
+ "country": place.get("country", ""),
129
+ "temp_c": current["temperature_2m"],
130
+ "apparent_temp_c": current["apparent_temperature"],
131
+ "sky": WEATHER_CODES.get(code, f"hava kodu {code}"),
132
+ "wind_kmh": current["wind_speed_10m"],
133
+ "observed_at": current["time"],
134
+ "source": "Open-Meteo",
135
+ }
136
+
137
+
138
+ def convert_temperature(value: float, from_unit: str, to_unit: str) -> dict[str, Any]:
139
+ if from_unit == to_unit:
140
+ converted = value
141
+ elif from_unit == "C" and to_unit == "F":
142
+ converted = value * 9 / 5 + 32
143
+ else:
144
+ converted = (value - 32) * 5 / 9
145
+ return {
146
+ "value": round(converted, 1),
147
+ "unit": to_unit,
148
+ "source_value": value,
149
+ "source_unit": from_unit,
150
+ }
151
+
152
+
153
+ FUNCTIONS = {
154
+ "get_weather": get_weather,
155
+ "convert_temperature": convert_temperature,
156
+ }
157
+
158
+
159
+ def _as_dict(item: Any) -> dict[str, Any]:
160
+ if hasattr(item, "model_dump"):
161
+ return item.model_dump(exclude_none=True)
162
+ return dict(item)
163
+
164
+
165
+ def _trace_markdown(events: list[dict[str, Any]]) -> str:
166
+ if not events:
167
+ return "_Henüz araç çağrısı yok._"
168
+ blocks = []
169
+ for event in events:
170
+ blocks.append(
171
+ f"### Turn {event['turn']} · `{event['tool']}`\n"
172
+ f"**→ Argümanlar**\n```json\n"
173
+ f"{json.dumps(event['arguments'], ensure_ascii=False, indent=2)}\n```\n"
174
+ f"**← Araç çıktısı**\n```json\n"
175
+ f"{json.dumps(event['result'], ensure_ascii=False, indent=2)}\n```"
176
+ )
177
+ return "\n\n".join(blocks)
178
+
179
+
180
+ def _provider_controls(provider: str):
181
+ models = provider_models(provider)
182
+ default = OPENROUTER_DEFAULT if provider == "OpenRouter" else MODEL
183
+ values = {value for _, value in models}
184
+ if default not in values:
185
+ default = models[0][1]
186
+ return (
187
+ gr.update(choices=models, value=default),
188
+ gr.update(visible=provider == "OpenAI"),
189
+ gr.update(visible=provider == "OpenRouter"),
190
+ )
191
+
192
+
193
+ def ask(question: str, provider: str, model: str, openai_key: str, openrouter_key: str):
194
+ question = (question or "").strip()
195
+ if provider == "OpenRouter":
196
+ key = (openrouter_key or "").strip() or os.getenv("OPENROUTER_API_KEY", "")
197
+ else:
198
+ key = (openai_key or "").strip() or os.getenv("OPENAI_API_KEY", "")
199
+ if not question:
200
+ return "Lütfen bir soru yazın.", "_Henüz araç çağrısı yok._"
201
+ if not key:
202
+ return (
203
+ (
204
+ f"{provider} API anahtarı gerekli. Space sahibi ilgili Secret'ı "
205
+ "tanımlayabilir veya anahtarı yalnızca bu istek için alana girebilirsiniz."
206
+ ),
207
+ "_İstek modele gönderilmedi._",
208
+ )
209
+
210
+ if provider == "OpenRouter":
211
+ try:
212
+ text, provider_trace = run_openrouter_tool_loop(
213
+ api_key=key,
214
+ model=model,
215
+ system_prompt=SYSTEM_PROMPT,
216
+ user_prompt=question,
217
+ tools=TOOLS,
218
+ execute_tool=lambda name, arguments: FUNCTIONS[name](**arguments),
219
+ max_turns=MAX_TURNS,
220
+ )
221
+ trace = [
222
+ {
223
+ "turn": item["turn"],
224
+ "tool": item["name"],
225
+ "arguments": item["detail"]["arguments"],
226
+ "result": item["detail"]["result"],
227
+ }
228
+ for item in provider_trace
229
+ ]
230
+ return text, _trace_markdown(trace)
231
+ except Exception as exc: # noqa: BLE001 - UI boundary must return a readable error
232
+ return f"İstek tamamlanamadı: `{type(exc).__name__}: {exc}`", "_Araç döngüsü tamamlanamadı._"
233
+
234
+ client = OpenAI(api_key=key)
235
+ conversation: list[Any] = [{"role": "user", "content": question}]
236
+ trace: list[dict[str, Any]] = []
237
+
238
+ try:
239
+ for turn in range(1, MAX_TURNS + 1):
240
+ response = client.responses.create(
241
+ model=model,
242
+ instructions=SYSTEM_PROMPT,
243
+ input=conversation,
244
+ tools=TOOLS,
245
+ reasoning={"effort": "low"},
246
+ )
247
+ output_items = [_as_dict(item) for item in response.output]
248
+ conversation.extend(output_items)
249
+ calls = [item for item in output_items if item.get("type") == "function_call"]
250
+ if not calls:
251
+ return response.output_text, _trace_markdown(trace)
252
+
253
+ for call in calls:
254
+ arguments: dict[str, Any] = {}
255
+ try:
256
+ arguments = json.loads(call.get("arguments") or "{}")
257
+ function = FUNCTIONS[call["name"]]
258
+ result = function(**arguments)
259
+ except Exception as exc: # noqa: BLE001 - tool errors are returned to the model
260
+ result = {"error": f"{type(exc).__name__}: {exc}"}
261
+ trace.append(
262
+ {
263
+ "turn": turn,
264
+ "tool": call["name"],
265
+ "arguments": arguments,
266
+ "result": result,
267
+ }
268
+ )
269
+ conversation.append(
270
+ {
271
+ "type": "function_call_output",
272
+ "call_id": call["call_id"],
273
+ "output": json.dumps(result, ensure_ascii=False),
274
+ }
275
+ )
276
+ return "Agent güvenlik tur sınırına ulaştı.", _trace_markdown(trace)
277
+ except Exception as exc: # noqa: BLE001 - UI boundary must return a readable error
278
+ return f"İstek tamamlanamadı: `{type(exc).__name__}: {exc}`", _trace_markdown(trace)
279
+
280
+
281
+ CSS = """
282
+ .gradio-container {max-width: 1120px !important; margin: auto !important}
283
+ .hero {background: linear-gradient(135deg,#0b1220,#173b5e); padding:28px;
284
+ border-radius:20px; color:#fff; margin-bottom:18px}
285
+ .hero h1 {margin:0 0 8px 0; font-size:2rem}
286
+ .hero p {opacity:.86; margin:0}
287
+ .panel {border:1px solid rgba(120,140,170,.22); border-radius:16px; padding:4px}
288
+ """
289
+
290
+ with gr.Blocks(css=CSS, title="Hava Durumu Tool Calling Agent") as demo:
291
+ gr.HTML(
292
+ """<div class="hero"><h1>⛅ Hava Durumu Tool Calling Agent</h1>
293
+ <p>Open‑Meteo verisi · OpenAI Responses API · Görünür araç izleri</p></div>"""
294
+ )
295
+ with gr.Row():
296
+ with gr.Column(scale=5, elem_classes="panel"):
297
+ provider_input = gr.Dropdown(
298
+ label="LLM sağlayıcısı",
299
+ choices=["OpenAI", "OpenRouter"],
300
+ value="OpenAI",
301
+ )
302
+ model_input = gr.Dropdown(
303
+ label="Tool-calling destekli model",
304
+ choices=provider_models("OpenAI"),
305
+ value=MODEL,
306
+ allow_custom_value=False,
307
+ )
308
+ question_input = gr.Textbox(
309
+ label="Sorunuz",
310
+ value="Ankara mı daha sıcak Londra mı, ve bu değerler Fahrenheit olarak kaç eder?",
311
+ lines=3,
312
+ )
313
+ openai_key_input = gr.Textbox(
314
+ label="OpenAI API anahtarı (Space Secret yoksa)",
315
+ type="password",
316
+ placeholder="sk-… — saklanmaz",
317
+ )
318
+ openrouter_key_input = gr.Textbox(
319
+ label="OpenRouter API anahtarı (Space Secret yoksa)",
320
+ type="password",
321
+ placeholder="sk-or-… — saklanmaz",
322
+ visible=False,
323
+ )
324
+ submit = gr.Button("Agentı çalıştır", variant="primary")
325
+ gr.Examples(
326
+ examples=[
327
+ ["İstanbul ve Berlin'in güncel sıcaklıklarını karşılaştır."],
328
+ ["İzmir'de hava nasıl? Fahrenheit olarak da söyle."],
329
+ ["Tokyo, Paris ve Ankara arasında en sıcak şehir hangisi?"],
330
+ ],
331
+ inputs=question_input,
332
+ )
333
+ with gr.Column(scale=7, elem_classes="panel"):
334
+ answer = gr.Markdown("Yanıt burada görünecek.")
335
+ with gr.Accordion("🔎 Araç çağrıları ve adımlar", open=True):
336
+ trace_view = gr.Markdown("_Henüz araç çağrısı yok._")
337
+
338
+ provider_input.change(
339
+ _provider_controls,
340
+ [provider_input],
341
+ [model_input, openai_key_input, openrouter_key_input],
342
+ )
343
+ ask_inputs = [
344
+ question_input,
345
+ provider_input,
346
+ model_input,
347
+ openai_key_input,
348
+ openrouter_key_input,
349
+ ]
350
+ submit.click(ask, ask_inputs, [answer, trace_view])
351
+ question_input.submit(ask, ask_inputs, [answer, trace_view])
352
+
353
+ if __name__ == "__main__":
354
+ demo.queue(default_concurrency_limit=8).launch()
llm_providers.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ from collections.abc import Callable
3
+ from typing import Any
4
+
5
+ import httpx
6
+ from openai import OpenAI
7
+
8
+ OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
9
+ OPENROUTER_DEFAULT = "openai/gpt-5.6-sol"
10
+ OPENAI_DEFAULT = "gpt-5.6-sol"
11
+ MODEL_TIMEOUT = 15.0
12
+
13
+ OPENAI_MODELS = [
14
+ ("GPT-5.6 Sol · en güçlü", "gpt-5.6-sol"),
15
+ ("GPT-5.6 Terra · dengeli", "gpt-5.6-terra"),
16
+ ("GPT-5.6 Luna · ekonomik", "gpt-5.6-luna"),
17
+ ]
18
+
19
+ FALLBACK_OPENROUTER_MODELS = [
20
+ ("OpenAI: GPT-5.6 Sol", "openai/gpt-5.6-sol"),
21
+ ("Anthropic: Claude Opus 5", "anthropic/claude-opus-5"),
22
+ ("Google: Gemini 3.6 Flash", "google/gemini-3.6-flash"),
23
+ ]
24
+
25
+
26
+ def fetch_openrouter_models(require_structured: bool = False) -> list[tuple[str, str]]:
27
+ """Return only models whose live OpenRouter metadata declares required capabilities."""
28
+ try:
29
+ response = httpx.get(
30
+ f"{OPENROUTER_BASE_URL}/models",
31
+ params={"supported_parameters": "tools"},
32
+ timeout=MODEL_TIMEOUT,
33
+ )
34
+ response.raise_for_status()
35
+ models = []
36
+ for item in response.json().get("data", []):
37
+ supported = set(item.get("supported_parameters") or [])
38
+ if "tools" not in supported:
39
+ continue
40
+ if require_structured and "structured_outputs" not in supported:
41
+ continue
42
+ model_id = item.get("id")
43
+ if not model_id or model_id.endswith(":batch"):
44
+ continue
45
+ models.append((item.get("name") or model_id, model_id))
46
+ if models:
47
+ priority = {
48
+ OPENROUTER_DEFAULT: 0,
49
+ "anthropic/claude-opus-5": 1,
50
+ "google/gemini-3.6-flash": 2,
51
+ }
52
+ return sorted(models, key=lambda pair: (priority.get(pair[1], 100), pair[0].lower()))
53
+ except Exception: # noqa: BLE001 - startup must survive catalog outages
54
+ pass
55
+ return FALLBACK_OPENROUTER_MODELS
56
+
57
+
58
+ def provider_models(provider: str, require_structured: bool = False) -> list[tuple[str, str]]:
59
+ if provider == "OpenRouter":
60
+ return fetch_openrouter_models(require_structured=require_structured)
61
+ return OPENAI_MODELS
62
+
63
+
64
+ def responses_tools_to_chat(tools: list[dict[str, Any]]) -> list[dict[str, Any]]:
65
+ return [
66
+ {
67
+ "type": "function",
68
+ "function": {
69
+ "name": tool["name"],
70
+ "description": tool.get("description", ""),
71
+ "parameters": tool["parameters"],
72
+ },
73
+ }
74
+ for tool in tools
75
+ if tool.get("type") == "function"
76
+ ]
77
+
78
+
79
+ def run_openrouter_tool_loop(
80
+ *,
81
+ api_key: str,
82
+ model: str,
83
+ system_prompt: str,
84
+ user_prompt: str,
85
+ tools: list[dict[str, Any]],
86
+ execute_tool: Callable[[str, dict[str, Any]], Any],
87
+ max_turns: int,
88
+ response_schema: dict[str, Any] | None = None,
89
+ ) -> tuple[str, list[dict[str, Any]]]:
90
+ client = OpenAI(base_url=OPENROUTER_BASE_URL, api_key=api_key)
91
+ chat_tools = responses_tools_to_chat(tools)
92
+ messages: list[dict[str, Any]] = [
93
+ {"role": "system", "content": system_prompt},
94
+ {"role": "user", "content": user_prompt},
95
+ ]
96
+ trace: list[dict[str, Any]] = []
97
+
98
+ for turn in range(1, max_turns + 1):
99
+ request: dict[str, Any] = {
100
+ "model": model,
101
+ "messages": messages,
102
+ "tools": chat_tools,
103
+ }
104
+ if response_schema:
105
+ request["response_format"] = {
106
+ "type": "json_schema",
107
+ "json_schema": {
108
+ "name": "agent_result",
109
+ "strict": True,
110
+ "schema": response_schema,
111
+ },
112
+ }
113
+ completion = client.chat.completions.create(**request)
114
+ message = completion.choices[0].message
115
+ calls = message.tool_calls or []
116
+ if not calls:
117
+ return message.content or "", trace
118
+
119
+ messages.append(message.model_dump(exclude_none=True))
120
+ for call in calls:
121
+ arguments: dict[str, Any] = {}
122
+ try:
123
+ arguments = json.loads(call.function.arguments or "{}")
124
+ result = execute_tool(call.function.name, arguments)
125
+ except Exception as exc: # noqa: BLE001 - tool failures must return to the model
126
+ result = {"error": f"{type(exc).__name__}: {exc}"}
127
+ trace.append(
128
+ {
129
+ "turn": turn,
130
+ "type": "function_call",
131
+ "name": call.function.name,
132
+ "detail": {"arguments": arguments, "result": result},
133
+ }
134
+ )
135
+ messages.append(
136
+ {
137
+ "role": "tool",
138
+ "tool_call_id": call.id,
139
+ "content": json.dumps(result, ensure_ascii=False),
140
+ }
141
+ )
142
+ raise RuntimeError("Agent güvenlik tur sınırına ulaştı.")
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ gradio==5.49.1
2
+ httpx>=0.27,<1
3
+ openai>=1.93,<3