magpie-gemini-web 0.3.3 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,531 @@
1
+ """Native Task HTTP/2 transport implementation."""
2
+ from __future__ import annotations
3
+
4
+ import collections
5
+ import queue
6
+ import secrets
7
+ import socket
8
+ import ssl
9
+ import subprocess
10
+ import threading
11
+ import time
12
+ from pathlib import Path
13
+ from typing import Any
14
+ from urllib.parse import unquote
15
+
16
+ import h2.config
17
+ import h2.connection
18
+ import h2.events
19
+
20
+ from .auth import AuthManager
21
+ from .config import RuntimeConfig, require_macos
22
+ from .contract import BridgeError, CompletionRequest, render_prompt
23
+ from .protocol import (
24
+ HOST,
25
+ MAX_FRAME,
26
+ MODE_ID,
27
+ ProtocolError,
28
+ SERVER_NAME,
29
+ TASK_RPC,
30
+ TOOLS_RPC,
31
+ TURN_RPC,
32
+ FrameReader,
33
+ TaskOutput,
34
+ build_task_request,
35
+ decode_struct,
36
+ fields,
37
+ frame,
38
+ messages,
39
+ one,
40
+ pb,
41
+ tool_reply,
42
+ turn_request,
43
+ validate_headers,
44
+ )
45
+
46
+
47
+ def is_app_running() -> bool:
48
+ try:
49
+ res = subprocess.run(["pgrep", "-x", "Gemini"], capture_output=True, timeout=5)
50
+ if res.returncode == 0:
51
+ return True
52
+ if res.returncode == 1:
53
+ return False
54
+ raise BridgeError("app_state_unavailable", "pgrep returned unexpected status.", 409)
55
+ except BridgeError:
56
+ raise
57
+ except Exception as e:
58
+ raise BridgeError("app_state_unavailable", "Cannot determine whether Gemini.app is running.", 409) from e
59
+
60
+
61
+ class H2Writer:
62
+ """Bounded HTTP/2 outbound writer with per-stream frame splitting and FIFO end markers."""
63
+
64
+ def __init__(self, client: h2.connection.H2Connection):
65
+ self.client = client
66
+ self.queues: dict[int, collections.deque[list[Any]]] = collections.defaultdict(collections.deque)
67
+ self.closed_streams: set[int] = set()
68
+
69
+ def queue_data(self, stream_id: int, data: bytes, end_stream: bool = False):
70
+ if stream_id in self.closed_streams:
71
+ raise ValueError(f"Cannot queue data on stream {stream_id} after end_stream.")
72
+ if end_stream:
73
+ self.closed_streams.add(stream_id)
74
+ self.queues[stream_id].append([bytearray(data), end_stream])
75
+
76
+ def drain(self) -> bool:
77
+ sent_any = False
78
+ active_streams = list(self.queues.keys())
79
+ for sid in active_streams:
80
+ q = self.queues[sid]
81
+ while q:
82
+ item = q[0]
83
+ chunk, is_end = item[0], item[1]
84
+ if len(chunk) == 0:
85
+ if is_end:
86
+ self.client.end_stream(sid)
87
+ q.popleft()
88
+ sent_any = True
89
+ continue
90
+ else:
91
+ q.popleft()
92
+ continue
93
+
94
+ window = self.client.local_flow_control_window(sid)
95
+ conn_window = self.client.outbound_flow_control_window
96
+ max_frame = self.client.max_outbound_frame_size
97
+ sendable = min(window, conn_window, max_frame, len(chunk))
98
+ if sendable <= 0:
99
+ break
100
+
101
+ to_send = bytes(chunk[:sendable])
102
+ del chunk[:sendable]
103
+ end_now = len(chunk) == 0 and is_end
104
+ if len(chunk) == 0:
105
+ q.popleft()
106
+
107
+ self.client.send_data(sid, to_send, end_stream=end_now)
108
+ sent_any = True
109
+ return sent_any
110
+
111
+
112
+ def grpc_failure(status: str, message: str = "") -> BridgeError:
113
+ if status == "8" and unquote(message).endswith(": Remy quota exhausted"):
114
+ return BridgeError("native_quota_exhausted", "The account's native Task quota is exhausted. Wait for its quota to reset.", 409)
115
+ return BridgeError("grpc_error", f"Native RPC failed with grpc-status {status}.", 409)
116
+
117
+
118
+ def unary(
119
+ headers: list[list[str]],
120
+ request_data: bytes,
121
+ *,
122
+ rpc_path: str,
123
+ timeout: float = 15.0,
124
+ ) -> tuple[dict[str, str], bytes]:
125
+ """Execute a single bounded gRPC unary request over HTTP/2."""
126
+ if rpc_path not in (TASK_RPC, TOOLS_RPC, TURN_RPC):
127
+ raise BridgeError("invalid_rpc_path", f"Path {rpc_path} is not permitted.")
128
+
129
+ validate_headers(headers, rpc_path)
130
+
131
+ deadline = time.monotonic() + timeout
132
+ context = ssl.create_default_context()
133
+ context.set_alpn_protocols(["h2"])
134
+
135
+ client = h2.connection.H2Connection(
136
+ config=h2.config.H2Configuration(client_side=True, header_encoding="utf-8")
137
+ )
138
+ writer = H2Writer(client)
139
+ reader = FrameReader()
140
+ response_headers: dict[str, str] = {}
141
+ response_data = bytearray()
142
+ stream_ended = False
143
+
144
+ remaining = deadline - time.monotonic()
145
+ if remaining <= 0:
146
+ raise BridgeError("timeout", "Unary request timed out.", 409)
147
+
148
+ with socket.create_connection((HOST, 443), timeout=min(timeout, remaining)) as raw_sock:
149
+ remaining = deadline - time.monotonic()
150
+ if remaining <= 0:
151
+ raise BridgeError("timeout", "Unary request timed out.", 409)
152
+ raw_sock.settimeout(remaining)
153
+ with context.wrap_socket(raw_sock, server_hostname=HOST, suppress_ragged_eofs=False) as sock:
154
+ if sock.selected_alpn_protocol() != "h2":
155
+ raise BridgeError("h2_not_negotiated", "Server did not negotiate HTTP/2.")
156
+
157
+ client.initiate_connection()
158
+ client.send_headers(1, headers)
159
+ writer.queue_data(1, request_data, end_stream=True)
160
+ writer.drain()
161
+ sock.sendall(client.data_to_send())
162
+
163
+ sock.settimeout(0.25)
164
+ while not stream_ended:
165
+ if time.monotonic() >= deadline:
166
+ raise BridgeError("timeout", "Unary request timed out.", 409)
167
+
168
+ writer.drain()
169
+ outgoing = client.data_to_send()
170
+ if outgoing:
171
+ sock.sendall(outgoing)
172
+
173
+ try:
174
+ data = sock.recv(65536)
175
+ except socket.timeout:
176
+ continue
177
+ except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError) as e:
178
+ raise BridgeError("transport_eof", "Connection terminated unexpectedly.", 409) from e
179
+
180
+ if not data:
181
+ break
182
+
183
+ for event in client.receive_data(data):
184
+ sid = getattr(event, "stream_id", None)
185
+ # WindowUpdated(stream_id=0) or None is connection-level; allow 0 and 1
186
+ if sid not in (None, 0, 1) and not isinstance(event, (h2.events.ConnectionTerminated, h2.events.PingAckReceived)):
187
+ raise BridgeError("unexpected_stream", "Unexpected application stream.", 409)
188
+
189
+ if isinstance(event, (h2.events.ResponseReceived, h2.events.TrailersReceived)):
190
+ if event.stream_id == 1:
191
+ response_headers.update(dict(event.headers))
192
+ status = response_headers.get(":status")
193
+ if status is not None and status != "200":
194
+ raise BridgeError("rpc_http_error", f"RPC HTTP status {status}", 409)
195
+ grpc_st = response_headers.get("grpc-status")
196
+ if grpc_st is not None and grpc_st != "0":
197
+ raise grpc_failure(grpc_st, response_headers.get("grpc-message", ""))
198
+ elif isinstance(event, h2.events.DataReceived):
199
+ if event.stream_id == 1:
200
+ client.acknowledge_received_data(event.flow_controlled_length, event.stream_id)
201
+ response_data.extend(event.data)
202
+ if len(response_data) > 16 * 1024 * 1024:
203
+ raise BridgeError("oversized_response", "Unary response exceeded 16 MiB.", 409)
204
+ reader.feed(event.data)
205
+ elif isinstance(event, h2.events.StreamEnded):
206
+ if event.stream_id == 1:
207
+ if response_headers.get(":status") != "200":
208
+ raise BridgeError("rpc_http_error", "RPC failed with non-200 status.", 409)
209
+ if response_headers.get("grpc-status") != "0":
210
+ raise BridgeError("grpc_error", "RPC failed with nonzero or missing grpc-status.", 409)
211
+ reader.finish()
212
+ stream_ended = True
213
+ elif isinstance(event, h2.events.WindowUpdated):
214
+ writer.drain()
215
+ elif isinstance(event, h2.events.StreamReset):
216
+ if event.stream_id == 1:
217
+ raise BridgeError("stream_reset", f"Stream 1 reset by server with code {event.error_code}.", 409)
218
+ elif isinstance(event, h2.events.ConnectionTerminated):
219
+ raise BridgeError("connection_terminated", "Connection terminated by server.", 409)
220
+
221
+ if not stream_ended:
222
+ raise BridgeError("transport_eof", "Connection closed before StreamEnded.", 409)
223
+
224
+ return response_headers, bytes(response_data)
225
+
226
+
227
+ class NativeTask:
228
+ """Active streaming Task transport session."""
229
+
230
+ def __init__(self, config: RuntimeConfig, request: CompletionRequest, sink, auth_manager: AuthManager | None = None):
231
+ self.config = config
232
+ self.request = request
233
+ self.sink = sink
234
+ self.auth_manager = auth_manager or AuthManager(config)
235
+ self.stop_event = threading.Event()
236
+ self.result_queue: queue.Queue[tuple[str, str]] = queue.Queue()
237
+ self._thread: threading.Thread | None = None
238
+
239
+ self.pending_call_id: str | None = None
240
+ self.pending_context: bytes | None = None
241
+ self.pending_function: bytes | None = None
242
+ self.pending_tool_name: str | None = None
243
+ self.conversation: bytes | None = None
244
+ self.output = TaskOutput()
245
+
246
+ def start(self) -> None:
247
+ self._thread = threading.Thread(target=self._run, daemon=True, name="NativeTaskTransport")
248
+ self._thread.start()
249
+
250
+ def submit_result(self, call_id: str, text: str) -> None:
251
+ self.result_queue.put((call_id, text))
252
+
253
+ def stop(self) -> None:
254
+ self.stop_event.set()
255
+
256
+ def _check_active(self, deadline: float, check_app: bool = False):
257
+ if self.stop_event.is_set():
258
+ raise BridgeError("cancelled", "Task locally cancelled; native_cancel_confirmed: false.", 409)
259
+ if time.monotonic() >= deadline:
260
+ raise BridgeError("task_timeout", f"Task exceeded budget of {self.config.task_timeout}s.", 409)
261
+ if check_app and is_app_running():
262
+ raise BridgeError("app_running", "Close Gemini before using the native Task provider.", 409)
263
+
264
+ def _run(self) -> None:
265
+ try:
266
+ require_macos()
267
+ deadline = time.monotonic() + self.config.task_timeout
268
+
269
+ self._check_active(deadline, check_app=True)
270
+
271
+ prompt = render_prompt(self.request)
272
+ task_template_path = self.config.data_dir / "source" / "task-template.bin"
273
+ if not task_template_path.exists():
274
+ raise BridgeError("missing_template", "task-template.bin is missing.", 409)
275
+ task_body = build_task_request(task_template_path.read_bytes(), prompt)
276
+
277
+ self._check_active(deadline, check_app=False)
278
+
279
+ task_headers, tool_headers = self.auth_manager.headers()
280
+
281
+ self._check_active(deadline, check_app=True)
282
+
283
+ context = ssl.create_default_context()
284
+ context.set_alpn_protocols(["h2"])
285
+
286
+ client = h2.connection.H2Connection(
287
+ config=h2.config.H2Configuration(client_side=True, header_encoding="utf-8")
288
+ )
289
+ writer = H2Writer(client)
290
+ task_reader = FrameReader()
291
+ tool_reader = FrameReader()
292
+
293
+ ended_streams: set[int] = set()
294
+ stream_headers: dict[int, dict[str, str]] = collections.defaultdict(dict)
295
+ tool_subscribed = False
296
+ last_rx = time.monotonic()
297
+ pending_ping: bytes | None = None
298
+
299
+ remaining = deadline - time.monotonic()
300
+ if remaining <= 0:
301
+ raise BridgeError("task_timeout", "Task timed out.", 409)
302
+
303
+ self._check_active(deadline)
304
+ with socket.create_connection((HOST, 443), timeout=min(15.0, remaining)) as raw_sock:
305
+ self._check_active(deadline)
306
+ raw_sock.settimeout(max(0.01, min(15.0, deadline - time.monotonic())))
307
+ with context.wrap_socket(raw_sock, server_hostname=HOST, suppress_ragged_eofs=False) as sock:
308
+ if sock.selected_alpn_protocol() != "h2":
309
+ raise BridgeError("h2_not_negotiated", "Server did not negotiate HTTP/2.")
310
+
311
+ self._check_active(deadline, check_app=True)
312
+
313
+ client.initiate_connection()
314
+ client.send_headers(1, task_headers)
315
+ writer.queue_data(1, task_body, end_stream=True)
316
+ writer.drain()
317
+
318
+ self._check_active(deadline, check_app=False)
319
+ sock.sendall(client.data_to_send())
320
+
321
+ sock.settimeout(0.25)
322
+
323
+ while True:
324
+ if 1 in ended_streams:
325
+ # Stream 1 ended. Require explicit conversation and tool stream finish
326
+ if not self.conversation:
327
+ raise BridgeError("incomplete_task", "Stream 1 ended without conversation identifier.", 409)
328
+ if 3 in ended_streams or not tool_subscribed:
329
+ break
330
+ elif {1, 3} <= ended_streams:
331
+ break
332
+
333
+ self._check_active(deadline, check_app=True)
334
+
335
+ try:
336
+ cid, text = self.result_queue.get_nowait()
337
+ if self.pending_call_id is None or cid != self.pending_call_id:
338
+ raise BridgeError("tool_call_id_mismatch", "Submitted result does not match pending call.", 409)
339
+ reply_payload = {"output": text, "tool": self.pending_tool_name, "server": SERVER_NAME}
340
+ reply_bytes = tool_reply(self.pending_function, self.pending_context, reply_payload)
341
+ writer.queue_data(3, frame(reply_bytes))
342
+ self.pending_call_id = None
343
+ self.pending_context = None
344
+ self.pending_function = None
345
+ self.pending_tool_name = None
346
+ except queue.Empty:
347
+ pass
348
+
349
+ now = time.monotonic()
350
+ if pending_ping is None and (now - last_rx) >= 15.0:
351
+ pending_ping = secrets.token_bytes(8)
352
+ client.ping(pending_ping)
353
+
354
+ writer.drain()
355
+ outgoing = client.data_to_send()
356
+ if outgoing:
357
+ sock.sendall(outgoing)
358
+
359
+ try:
360
+ data = sock.recv(65536)
361
+ except socket.timeout:
362
+ continue
363
+ except (ssl.SSLEOFError, ConnectionResetError, BrokenPipeError) as e:
364
+ raise BridgeError("transport_eof", "Transport closed unexpectedly.", 409) from e
365
+
366
+ if not data:
367
+ raise BridgeError("transport_eof", "Transport EOF received.", 409)
368
+
369
+ last_rx = time.monotonic()
370
+ for event in client.receive_data(data):
371
+ sid = getattr(event, "stream_id", None)
372
+ if sid not in (None, 0, 1, 3) and not isinstance(event, (h2.events.ConnectionTerminated, h2.events.PingAckReceived)):
373
+ raise BridgeError("unexpected_stream", "Unexpected application stream.", 409)
374
+
375
+ if isinstance(event, (h2.events.ResponseReceived, h2.events.TrailersReceived)):
376
+ if sid in (1, 3):
377
+ stream_headers[sid].update(dict(event.headers))
378
+ st = stream_headers[sid].get(":status")
379
+ if st is not None and st != "200":
380
+ raise BridgeError("rpc_http_error", f"Stream {sid} failed with HTTP status {st}", 409)
381
+ grpc_st = stream_headers[sid].get("grpc-status")
382
+ if grpc_st is not None and grpc_st != "0":
383
+ raise grpc_failure(grpc_st, stream_headers[sid].get("grpc-message", ""))
384
+ elif isinstance(event, h2.events.DataReceived):
385
+ if sid in (1, 3):
386
+ client.acknowledge_received_data(event.flow_controlled_length, sid)
387
+ reader = task_reader if sid == 1 else tool_reader
388
+ msgs = reader.feed(event.data)
389
+ for msg in msgs:
390
+ if sid == 1:
391
+ self._handle_task_message(msg, client, writer, tool_headers, tool_subscribed)
392
+ if self.conversation and not tool_subscribed:
393
+ tool_subscribed = True
394
+ else:
395
+ self._handle_tool_message(msg, writer)
396
+ elif isinstance(event, h2.events.StreamEnded):
397
+ if sid in (1, 3):
398
+ # Require accumulated status 200 and grpc-status 0
399
+ st = stream_headers[sid].get(":status")
400
+ grpc_st = stream_headers[sid].get("grpc-status")
401
+ if st != "200":
402
+ raise BridgeError("rpc_http_error", f"Stream {sid} ended with invalid HTTP status {st}", 409)
403
+ if grpc_st != "0":
404
+ raise BridgeError("grpc_error", f"Stream {sid} ended with invalid or missing grpc-status {grpc_st}", 409)
405
+
406
+ reader = task_reader if sid == 1 else tool_reader
407
+ reader.finish()
408
+ ended_streams.add(sid)
409
+ elif isinstance(event, h2.events.WindowUpdated):
410
+ writer.drain()
411
+ elif isinstance(event, h2.events.PingAckReceived):
412
+ if pending_ping is not None and event.ping_data == pending_ping:
413
+ pending_ping = None
414
+ elif isinstance(event, h2.events.StreamReset):
415
+ if sid in (1, 3):
416
+ raise BridgeError("stream_reset", f"Stream {sid} reset by upstream.", 409)
417
+ elif isinstance(event, h2.events.ConnectionTerminated):
418
+ if not {1, 3} <= ended_streams:
419
+ raise BridgeError("connection_terminated", "Connection terminated by server.", 409)
420
+
421
+ if self.pending_call_id is not None:
422
+ raise BridgeError("pending_tool_lost", "Transport ended while a tool call was pending.", 409)
423
+
424
+ if self.output.complete:
425
+ self.output.check_route(final=True)
426
+ self.sink.complete(self.output.text)
427
+ return
428
+
429
+ self.sink.complete(self._recover_saved_turn(deadline))
430
+
431
+ except BridgeError as be:
432
+ self.sink.fail(be)
433
+ except ProtocolError:
434
+ self.sink.fail(BridgeError("native_protocol_error", "Native response failed protocol or model validation.", 409))
435
+ except Exception:
436
+ self.sink.fail(BridgeError("transport_failure", "Native transport encountered a fatal error.", 409))
437
+
438
+ def _recover_saved_turn(self, deadline: float) -> str:
439
+ template_path = self.config.data_dir / "source" / "turn-template.bin"
440
+ if not template_path.exists():
441
+ raise BridgeError("incomplete_task", "Stored-turn template is missing.", 409)
442
+ template = template_path.read_bytes()
443
+ read_deadline = min(time.monotonic() + 20.0, deadline)
444
+ while time.monotonic() < read_deadline:
445
+ self._check_active(read_deadline)
446
+ task_headers, _ = self.auth_manager.headers()
447
+ self._check_active(read_deadline)
448
+ headers = [[k, TURN_RPC if k == ":path" else v] for k, v in task_headers]
449
+ try:
450
+ request = turn_request(template, self.conversation)
451
+ _, response = unary(headers, request, rpc_path=TURN_RPC,
452
+ timeout=min(15.0, read_deadline - time.monotonic()))
453
+ self._check_active(read_deadline)
454
+ if self.output.feed_saved(response) and self.output.complete:
455
+ self.output.check_route(final=True)
456
+ return self.output.text
457
+ except ProtocolError:
458
+ raise BridgeError("native_protocol_error", "Stored turn failed protocol or model validation.", 409) from None
459
+ except BridgeError as error:
460
+ if error.code not in {"transport_eof", "transport_failure", "timeout", "connection_terminated"}:
461
+ raise
462
+ except (OSError, ssl.SSLError):
463
+ pass # Read-only retrieval may retry within this one bounded budget.
464
+ if self.stop_event.wait(max(0, min(2.0, read_deadline - time.monotonic()))):
465
+ self._check_active(read_deadline)
466
+ raise BridgeError("incomplete_task", "Task ended without complete final text.", 409)
467
+
468
+ def _handle_task_message(
469
+ self,
470
+ msg: bytes,
471
+ client: h2.connection.H2Connection,
472
+ writer: H2Writer,
473
+ tool_headers: list[list[str]],
474
+ tool_subscribed: bool,
475
+ ):
476
+ self.output.feed(msg)
477
+ self.output.check_route(final=False)
478
+ if self.output.conversation and not tool_subscribed:
479
+ self.conversation = self.output.conversation
480
+ client.send_headers(3, tool_headers)
481
+ writer.queue_data(3, frame(pb(1, pb(1, self.conversation))))
482
+
483
+ def _handle_tool_message(self, msg: bytes, writer: H2Writer):
484
+ for n, w, v, _ in fields(msg):
485
+ if n == 5 and v:
486
+ if self.conversation and v != self.conversation:
487
+ raise BridgeError("conversation_mismatch", "Tool conversation mismatch.", 409)
488
+ elif n == 3:
489
+ if self.pending_call_id is not None:
490
+ raise BridgeError("pending_tool_lost", "Upstream ended tool stream while a tool was pending.", 409)
491
+ writer.queue_data(3, b"", end_stream=True)
492
+ elif n == 2 and w == 2:
493
+ call = one(v, 1)
494
+ func_name_bytes = one(call, 1)
495
+ field2 = one(call, 2)
496
+ payload = decode_struct(one(call, 3))
497
+
498
+ if func_name_bytes == b"list_mcp":
499
+ if payload:
500
+ raise BridgeError("invalid_list_args", "Unexpected arguments for list_mcp.", 409)
501
+ ad = self.request.active_catalog.advertisement()
502
+ reply_bytes = tool_reply(func_name_bytes, field2, ad)
503
+ writer.queue_data(3, frame(reply_bytes))
504
+ elif func_name_bytes == b"execute_mcp":
505
+ srv = payload.get("ServerName")
506
+ tool_name = payload.get("ToolName")
507
+ args = payload.get("Arguments")
508
+ if srv != SERVER_NAME:
509
+ raise BridgeError("invalid_server_name", "Invalid server name in execute_mcp payload.", 409)
510
+ allowed_keys = {"ServerName", "ToolName", "Arguments", "destructiveHint"}
511
+ if set(payload.keys()) - allowed_keys:
512
+ raise BridgeError("unexpected_tool_keys", "Unexpected envelope keys in execute_mcp.", 409)
513
+ if "destructiveHint" in payload and not isinstance(payload["destructiveHint"], bool):
514
+ raise BridgeError("invalid_destructive_hint", "destructiveHint must be a boolean.", 409)
515
+ if not isinstance(tool_name, str) or not isinstance(args, dict):
516
+ raise BridgeError("invalid_execute_payload", "Malformed execute_mcp payload.", 409)
517
+
518
+ self.request.active_catalog.validate(tool_name, args)
519
+
520
+ if self.pending_call_id is not None:
521
+ raise BridgeError("unsupported_parallel_call", "Simultaneous parallel tool calls are not supported.", 409)
522
+
523
+ downstream_id = self.sink.tool(tool_name, args)
524
+ if not downstream_id or self.stop_event.is_set():
525
+ raise BridgeError("cancelled", "Task is no longer active.", 409)
526
+ self.pending_call_id = downstream_id
527
+ self.pending_context = field2
528
+ self.pending_function = func_name_bytes
529
+ self.pending_tool_name = tool_name
530
+ else:
531
+ raise BridgeError("unsupported_native_function", "Unsupported native tool function requested.", 409)
@@ -0,0 +1,76 @@
1
+ import { createForwarder, ensureService } from './lib/runtime.mjs';
2
+ const ID = 'gemini-native-task';
3
+ const MODEL = 'gemini-task-argon';
4
+ function fail(message, signIn = 'kept') {
5
+ return Object.assign(new Error(message), { signIn });
6
+ }
7
+ export default {
8
+ id: ID,
9
+ server: async (_input, options = {}) => ({
10
+ config(config) {
11
+ config.provider ??= {};
12
+ const p = (config.provider[ID] ??= {});
13
+ p.npm ??= '@ai-sdk/openai-compatible';
14
+ p.name ??= 'Gemini Native Task';
15
+ p.models ??= {};
16
+ p.models[MODEL] ??= {
17
+ name: 'Argon Task', tool_call: true, reasoning: false, attachment: false,
18
+ temperature: false, modalities: { input: ['text'], output: ['text'] },
19
+ limit: { context: 65536, output: 8192 },
20
+ };
21
+ },
22
+ provider: {
23
+ id: ID,
24
+ models(given) {
25
+ const m = given?.models?.[MODEL] ?? {};
26
+ return { [MODEL]: {
27
+ ...m, id: MODEL, providerID: given?.id ?? ID, name: m.name ?? 'Argon Task',
28
+ api: { id: MODEL, npm: '@ai-sdk/openai-compatible', ...(m.api ?? {}) },
29
+ limit: { context: 65536, output: 8192, ...(m.limit ?? {}) },
30
+ capabilities: {
31
+ ...(m.capabilities ?? {}), temperature: false, reasoning: false, attachment: false,
32
+ toolcall: true, input: { text: true, image: false, audio: false, video: false, pdf: false },
33
+ output: { text: true, image: false, audio: false, video: false, pdf: false },
34
+ },
35
+ } };
36
+ },
37
+ },
38
+ auth: {
39
+ provider: ID, maxConcurrency: 1,
40
+ methods: [{
41
+ type: 'oauth', label: '连接本机 Task 服务',
42
+ async authorize() {
43
+ const service = await ensureService(options);
44
+ return {
45
+ url: '', method: 'auto', instructions: '',
46
+ callback: async () => ({
47
+ type: 'success', key: service.apiKey, accountId: `native:${service.instance_id}`,
48
+ metadata: { dataDir: service.dataDir, baseURL: `${service.baseURL}/v1` },
49
+ }),
50
+ };
51
+ },
52
+ }],
53
+ async loader(getAuth) {
54
+ const auth = await getAuth();
55
+ if (auth?.type !== 'api' || typeof auth.key !== 'string' || !auth.key)
56
+ throw fail('Connect the local Native Task service first.');
57
+ const service = await ensureService({
58
+ dataDir: auth.metadata?.dataDir ?? options.dataDir,
59
+ startupTimeoutMs: options.startupTimeoutMs,
60
+ });
61
+ if (auth.key !== service.apiKey)
62
+ throw fail('The saved local bridge key no longer matches this service. Connect it again.', 'expired');
63
+ return { baseURL: `${service.baseURL}/v1`, apiKey: auth.key, fetch: createForwarder(service) };
64
+ },
65
+ },
66
+ async 'chat.headers'(input, output) {
67
+ // The host's provider info retains the originating provider identity.
68
+ if (input?.model?.providerID !== ID && input?.provider?.info?.id !== ID) return;
69
+ if (typeof input.sessionID === 'string' && input.sessionID) {
70
+ output.headers ??= {};
71
+ output.headers['X-Gemini-Session'] = input.sessionID;
72
+ }
73
+ // Missing stable IDs are rejected by the daemon, never invented here.
74
+ },
75
+ }),
76
+ };