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.
- package/README.en.md +9 -3
- package/README.md +9 -3
- package/index.mjs +15 -1
- package/lib/backend.mjs +5 -3
- package/lib/task.mjs +87 -0
- package/native-task/LICENSE +21 -0
- package/native-task/README.md +87 -0
- package/native-task/bridge/gemini_native_bridge/__init__.py +3 -0
- package/native-task/bridge/gemini_native_bridge/__main__.py +235 -0
- package/native-task/bridge/gemini_native_bridge/auth.py +143 -0
- package/native-task/bridge/gemini_native_bridge/config.py +222 -0
- package/native-task/bridge/gemini_native_bridge/contract.py +283 -0
- package/native-task/bridge/gemini_native_bridge/native_auth_keychain.swift +111 -0
- package/native-task/bridge/gemini_native_bridge/oauth.py +154 -0
- package/native-task/bridge/gemini_native_bridge/protocol.py +363 -0
- package/native-task/bridge/gemini_native_bridge/server.py +441 -0
- package/native-task/bridge/gemini_native_bridge/state.py +806 -0
- package/native-task/bridge/gemini_native_bridge/transport.py +531 -0
- package/native-task/index.mjs +76 -0
- package/native-task/lib/runtime.mjs +189 -0
- package/native-task/pyproject.toml +18 -0
- package/native-task/requirements.txt +2 -0
- package/native-task/scripts/bridge.mjs +97 -0
- package/package.json +11 -3
|
@@ -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
|
+
};
|