coderouter-cli 2.13.0__py3-none-any.whl → 2.15.0__py3-none-any.whl
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.
- coderouter/adapters/anthropic_native.py +110 -3
- coderouter/adapters/base.py +65 -0
- coderouter/adapters/openai_compat.py +65 -2
- coderouter/cli.py +140 -2
- coderouter/config/loader.py +24 -1
- coderouter/config/schemas.py +209 -0
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/ingress/anthropic_routes.py +82 -12
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/openai_routes.py +31 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/logging.py +150 -0
- coderouter/metrics/collector.py +40 -0
- coderouter/metrics/prometheus.py +46 -0
- coderouter/rollback.py +217 -0
- coderouter/routing/__init__.py +8 -0
- coderouter/routing/fallback.py +304 -4
- coderouter/routing/fallback_trace.py +501 -0
- coderouter/secret_redaction.py +606 -0
- coderouter/state/audit_log.py +10 -0
- coderouter/state/request_log.py +6 -0
- coderouter/vscode_init.py +166 -23
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/METADATA +2 -2
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/RECORD +29 -24
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/WHEEL +1 -1
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.15.0.dist-info}/licenses/LICENSE +0 -0
coderouter/config/schemas.py
CHANGED
|
@@ -371,6 +371,120 @@ RESTART_COMMAND_SHELL_META_RE = re.compile(r"(&&|\|\||\||;|>|<|\$\(|`)")
|
|
|
371
371
|
RESTART_COMMAND_ENV_PREFIX_RE = re.compile(r"^\w+=")
|
|
372
372
|
|
|
373
373
|
|
|
374
|
+
class CredentialRefresh(BaseModel):
|
|
375
|
+
"""How to make a vendor CLI rotate the token it wrote to disk.
|
|
376
|
+
|
|
377
|
+
Deliberately not an OAuth client. Every vendor has its own endpoint,
|
|
378
|
+
client id, rotation policy and error shape, and all of them change —
|
|
379
|
+
delegating to the CLI that owns the file is the version that survives
|
|
380
|
+
contact with a moving vendor.
|
|
381
|
+
|
|
382
|
+
``command`` is an argv **list**, dispatched with ``shell=False``. Same
|
|
383
|
+
trust decision as v2.13.0's ``restart_command``: a string that goes
|
|
384
|
+
through a shell turns a config file into arbitrary code execution.
|
|
385
|
+
There is no string form here to have to refuse.
|
|
386
|
+
"""
|
|
387
|
+
|
|
388
|
+
model_config = ConfigDict(extra="forbid")
|
|
389
|
+
|
|
390
|
+
command: list[str] = Field(
|
|
391
|
+
...,
|
|
392
|
+
min_length=1,
|
|
393
|
+
description=(
|
|
394
|
+
"argv for the vendor CLI, e.g. ['grok', 'models']. Run with "
|
|
395
|
+
"shell=False; no shell metacharacters are interpreted."
|
|
396
|
+
),
|
|
397
|
+
)
|
|
398
|
+
early_ratio: float = Field(
|
|
399
|
+
default=0.5,
|
|
400
|
+
ge=0.0,
|
|
401
|
+
le=1.0,
|
|
402
|
+
description=(
|
|
403
|
+
"Refresh when the remaining lifetime drops below this fraction "
|
|
404
|
+
"of itself. Early refresh avoids a token dying mid-request."
|
|
405
|
+
),
|
|
406
|
+
)
|
|
407
|
+
min_lead_s: float = Field(
|
|
408
|
+
default=300.0,
|
|
409
|
+
ge=0.0,
|
|
410
|
+
description="Always refresh at least this many seconds before expiry.",
|
|
411
|
+
)
|
|
412
|
+
timeout_s: float = Field(
|
|
413
|
+
default=30.0,
|
|
414
|
+
gt=0.0,
|
|
415
|
+
le=600.0,
|
|
416
|
+
description="Hard timeout for the refresh command.",
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class ProviderCredential(BaseModel):
|
|
421
|
+
"""Where this provider's credential comes from (v2.14.0).
|
|
422
|
+
|
|
423
|
+
``source: env`` is the historical behaviour, spelled out. ``source:
|
|
424
|
+
cli_session`` borrows the token a vendor CLI already wrote to disk, so
|
|
425
|
+
a subscription-authenticated provider can be an ordinary
|
|
426
|
+
``openai_compat`` entry instead of a ``kind: agent_cli`` island — and
|
|
427
|
+
therefore takes part in fallback chains, auto-routing, the budget
|
|
428
|
+
tracker and every other routing feature.
|
|
429
|
+
"""
|
|
430
|
+
|
|
431
|
+
model_config = ConfigDict(extra="forbid")
|
|
432
|
+
|
|
433
|
+
source: Literal["env", "cli_session"] = Field(
|
|
434
|
+
default="env",
|
|
435
|
+
description="'env' reads an environment variable; 'cli_session' reads a JSON file.",
|
|
436
|
+
)
|
|
437
|
+
env: str | None = Field(
|
|
438
|
+
default=None, description="source=env: the environment variable name."
|
|
439
|
+
)
|
|
440
|
+
path: str | None = Field(
|
|
441
|
+
default=None,
|
|
442
|
+
description=(
|
|
443
|
+
"source=cli_session: the JSON file the vendor CLI writes, e.g. "
|
|
444
|
+
"~/.kimi-code/credentials/kimi-code.json. Must live under $HOME."
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
field: str = Field(
|
|
448
|
+
default="access_token",
|
|
449
|
+
description="Dotted path to the token inside that JSON (e.g. 'tokens.access').",
|
|
450
|
+
)
|
|
451
|
+
expiry_field: str = Field(
|
|
452
|
+
default="expires_at",
|
|
453
|
+
description=(
|
|
454
|
+
"Dotted path to the expiry. Epoch seconds or milliseconds; both "
|
|
455
|
+
"are accepted. Absent means 'no expiry info' — the upstream 401 "
|
|
456
|
+
"becomes the signal instead."
|
|
457
|
+
),
|
|
458
|
+
)
|
|
459
|
+
refresh: CredentialRefresh | None = Field(
|
|
460
|
+
default=None,
|
|
461
|
+
description="Omit to never refresh (a long-lived token, or one you rotate yourself).",
|
|
462
|
+
)
|
|
463
|
+
|
|
464
|
+
@model_validator(mode="after")
|
|
465
|
+
def _check_source_requirements(self) -> Self:
|
|
466
|
+
"""Each source needs its own field, and only its own field."""
|
|
467
|
+
if self.source == "env":
|
|
468
|
+
if not self.env:
|
|
469
|
+
raise ValueError("credential.source='env' requires credential.env")
|
|
470
|
+
if self.path:
|
|
471
|
+
raise ValueError("credential.path is meaningless for source='env'")
|
|
472
|
+
if self.source == "cli_session":
|
|
473
|
+
if not self.path:
|
|
474
|
+
raise ValueError(
|
|
475
|
+
"credential.source='cli_session' requires credential.path"
|
|
476
|
+
)
|
|
477
|
+
if self.env:
|
|
478
|
+
raise ValueError("credential.env is meaningless for source='cli_session'")
|
|
479
|
+
from coderouter.credentials import session_path_is_sane
|
|
480
|
+
|
|
481
|
+
if not session_path_is_sane(self.path):
|
|
482
|
+
raise ValueError(
|
|
483
|
+
f"credential.path must live under your home directory: {self.path}"
|
|
484
|
+
)
|
|
485
|
+
return self
|
|
486
|
+
|
|
487
|
+
|
|
374
488
|
class ProviderConfig(BaseModel):
|
|
375
489
|
"""A single provider entry from providers.yaml.
|
|
376
490
|
|
|
@@ -416,6 +530,19 @@ class ProviderConfig(BaseModel):
|
|
|
416
530
|
default=None,
|
|
417
531
|
description="Env var name holding the API key. None = no auth (e.g. local).",
|
|
418
532
|
)
|
|
533
|
+
# v2.14.0: the general form of the above. Mutually exclusive with
|
|
534
|
+
# api_key_env — a resolver that silently fell back between sources would
|
|
535
|
+
# make "which credential is this request actually using?" unanswerable,
|
|
536
|
+
# which is the question an operator asks at exactly the wrong moment.
|
|
537
|
+
credential: ProviderCredential | None = Field(
|
|
538
|
+
default=None,
|
|
539
|
+
description=(
|
|
540
|
+
"Where the credential comes from. Omit to use api_key_env. "
|
|
541
|
+
"source='cli_session' reads the token a vendor CLI already wrote "
|
|
542
|
+
"to disk, so a subscription provider can be a plain "
|
|
543
|
+
"openai_compat entry that takes part in fallback chains."
|
|
544
|
+
),
|
|
545
|
+
)
|
|
419
546
|
|
|
420
547
|
# Routing-relevant flags
|
|
421
548
|
paid: bool = Field(
|
|
@@ -611,6 +738,23 @@ class ProviderConfig(BaseModel):
|
|
|
611
738
|
validate_output_filters(self.output_filters)
|
|
612
739
|
return self
|
|
613
740
|
|
|
741
|
+
@model_validator(mode="after")
|
|
742
|
+
def _check_credential_exclusive(self) -> Self:
|
|
743
|
+
"""``api_key_env`` and ``credential`` cannot both be set.
|
|
744
|
+
|
|
745
|
+
Failing at load time is the whole point: two credential sources on
|
|
746
|
+
one provider is a question ("which one won?") that should never
|
|
747
|
+
reach a request, let alone a log line an operator has to reverse
|
|
748
|
+
engineer at 2am.
|
|
749
|
+
"""
|
|
750
|
+
if self.credential is not None and self.api_key_env:
|
|
751
|
+
raise ValueError(
|
|
752
|
+
f"provider {self.name!r}: set either api_key_env or credential, "
|
|
753
|
+
"not both (credential.source='env' with credential.env=... is "
|
|
754
|
+
"the spelled-out form of api_key_env)"
|
|
755
|
+
)
|
|
756
|
+
return self
|
|
757
|
+
|
|
614
758
|
@model_validator(mode="after")
|
|
615
759
|
def _check_kind_requirements(self) -> ProviderConfig:
|
|
616
760
|
"""Enforce per-``kind`` field requirements (external-agents design §5.2.2).
|
|
@@ -1088,6 +1232,71 @@ class FallbackChain(BaseModel):
|
|
|
1088
1232
|
),
|
|
1089
1233
|
)
|
|
1090
1234
|
|
|
1235
|
+
# --- v2.15.0 (stream-truncation): upstream SSE cut without a terminator --
|
|
1236
|
+
#
|
|
1237
|
+
# A streaming upstream can end its HTTP body *cleanly* while the LLM
|
|
1238
|
+
# protocol carried inside it is still mid-message: no ``message_stop`` on
|
|
1239
|
+
# the Anthropic wire, no ``data: [DONE]`` and no ``finish_reason`` on the
|
|
1240
|
+
# OpenAI wire. llama.cpp slot preemption, an ``--n-predict`` cut-off, a
|
|
1241
|
+
# front proxy closing an EOF-delimited body, or an OOM'd local server all
|
|
1242
|
+
# produce exactly this shape. Transport-level breakage (timeout,
|
|
1243
|
+
# ``httpx.RemoteProtocolError``) was already caught; this is the layer the
|
|
1244
|
+
# transport cannot see.
|
|
1245
|
+
#
|
|
1246
|
+
# Before v2.15.0 that stream was indistinguishable from a complete one:
|
|
1247
|
+
# the adapters never recorded whether a terminator arrived, and the
|
|
1248
|
+
# translation layer's terminator-synthesis guards (H6 / M9 in
|
|
1249
|
+
# ``coderouter.translation.convert``) then fabricated a ``stop_reason:
|
|
1250
|
+
# end_turn`` / ``finish_reason: "stop"`` so the client would not hang. The
|
|
1251
|
+
# synthesis is correct and stays; what was missing is telling the *engine*
|
|
1252
|
+
# that it happened.
|
|
1253
|
+
#
|
|
1254
|
+
# * ``off`` — no detection, no log, no metric. Backward-compatible
|
|
1255
|
+
# default: byte-for-byte identical to v2.14.0 on every path.
|
|
1256
|
+
# * ``warn`` — emit a ``stream-truncation-detected`` log line (and the
|
|
1257
|
+
# ``stream_truncated_total`` metric); the stream still
|
|
1258
|
+
# terminates through the legacy synthesis path, so the
|
|
1259
|
+
# client sees exactly what it saw before.
|
|
1260
|
+
# * ``error`` — the adapter raises ``StreamTruncatedError`` (a retryable
|
|
1261
|
+
# ``AdapterError``) at the point the terminator should have
|
|
1262
|
+
# arrived. The engine's existing branches take over: no
|
|
1263
|
+
# bytes forwarded yet → fall back to the next provider with
|
|
1264
|
+
# reason ``stream-truncated``; bytes already forwarded →
|
|
1265
|
+
# ``MidStreamError``, which ``partial_stitch_action:
|
|
1266
|
+
# surface`` renders as a graceful close carrying a
|
|
1267
|
+
# ``coderouter_partial`` event with reason
|
|
1268
|
+
# ``stream_truncated``.
|
|
1269
|
+
#
|
|
1270
|
+
# Caveats for ``error``:
|
|
1271
|
+
# * False positives are possible against an upstream that legitimately
|
|
1272
|
+
# omits the terminator. A ``message_delta`` carrying a ``stop_reason``
|
|
1273
|
+
# (Anthropic) and a ``finish_reason`` on any choice (OpenAI) are both
|
|
1274
|
+
# accepted as terminators to keep that risk low, but running ``warn``
|
|
1275
|
+
# first to measure the real rate is the recommended rollout.
|
|
1276
|
+
# * Falling back re-generates the answer on the next provider, so the
|
|
1277
|
+
# tokens the truncated attempt burned are paid twice.
|
|
1278
|
+
# * On the Anthropic streaming path the pre-content fallback requires
|
|
1279
|
+
# ``empty_response_action: fallback`` as well — that is the knob that
|
|
1280
|
+
# withholds the opening events from the client. Without it the opening
|
|
1281
|
+
# ``message_start`` has already shipped, so a truncation is by
|
|
1282
|
+
# definition mid-stream.
|
|
1283
|
+
stream_truncation_action: Literal["off", "warn", "error"] = Field(
|
|
1284
|
+
default="off",
|
|
1285
|
+
description=(
|
|
1286
|
+
"v2.15.0: action when an upstream SSE stream ends without its "
|
|
1287
|
+
"protocol terminator (no ``message_stop`` / no ``[DONE]`` and no "
|
|
1288
|
+
"``finish_reason``). ``off`` (default) does nothing — identical to "
|
|
1289
|
+
"v2.14.0. ``warn`` emits a ``stream-truncation-detected`` log and "
|
|
1290
|
+
"metric while the stream still ends through the legacy terminator "
|
|
1291
|
+
"synthesis. ``error`` raises a retryable ``StreamTruncatedError`` "
|
|
1292
|
+
"so the engine falls back to the next provider (reason "
|
|
1293
|
+
"``stream-truncated``) when nothing has reached the client yet, or "
|
|
1294
|
+
"raises ``MidStreamError`` when it has. Pair with "
|
|
1295
|
+
"``empty_response_action: fallback`` for pre-content fallback on "
|
|
1296
|
+
"the Anthropic streaming path."
|
|
1297
|
+
),
|
|
1298
|
+
)
|
|
1299
|
+
|
|
1091
1300
|
# --- ⑧ (empty-response): per-request empty-response fallback ------------
|
|
1092
1301
|
#
|
|
1093
1302
|
# Some local backends (observed: gemma4:26b on ``no_tool_temptation``
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Credential resolution, including CLI-session borrowing (v2.14.0).
|
|
2
|
+
|
|
3
|
+
Until now a provider's credential could only be an env var. That is fine
|
|
4
|
+
for API keys and useless for the thing an increasing number of vendors
|
|
5
|
+
actually sell: a subscription, authenticated by an OAuth token that their
|
|
6
|
+
own CLI already fetched and wrote to disk.
|
|
7
|
+
|
|
8
|
+
CodeRouter's existing answer to those was ``kind: agent_cli`` — spawn the
|
|
9
|
+
vendor's CLI once per request and read its stdout. It works, and it
|
|
10
|
+
throws away almost everything CodeRouter is for. A one-shot ``exec`` has
|
|
11
|
+
no streaming, no tool-call repair, no context-budget guard, and it does
|
|
12
|
+
not sit in a fallback chain the way an HTTP provider does. The vendor's
|
|
13
|
+
subscription ends up as an island.
|
|
14
|
+
|
|
15
|
+
The alternative, which is what codex-router does for Kimi and Grok, is to
|
|
16
|
+
skip the process entirely: **read the token the CLI already wrote, and
|
|
17
|
+
make the HTTP call ourselves.** The provider then becomes an ordinary
|
|
18
|
+
``openai_compat`` entry, and every routing feature applies to it —
|
|
19
|
+
"use the subscription until it 429s, then fall through to a free cloud
|
|
20
|
+
model, then to local llama.cpp" becomes an ordinary chain.
|
|
21
|
+
|
|
22
|
+
- name: kimi-sub
|
|
23
|
+
kind: openai_compat
|
|
24
|
+
base_url: https://api.moonshot.cn/v1
|
|
25
|
+
credential:
|
|
26
|
+
source: cli_session
|
|
27
|
+
path: ~/.kimi-code/credentials/kimi-code.json
|
|
28
|
+
field: access_token
|
|
29
|
+
expiry_field: expires_at
|
|
30
|
+
refresh:
|
|
31
|
+
command: ["kimi", "auth", "status"]
|
|
32
|
+
|
|
33
|
+
Refresh: delegate, don't reimplement
|
|
34
|
+
------------------------------------
|
|
35
|
+
Refreshing is the part that rots. Every vendor has its own endpoint,
|
|
36
|
+
client id, rotation policy and error shape, and all of them change. So
|
|
37
|
+
this module does not implement OAuth. It runs the vendor's own CLI and
|
|
38
|
+
re-reads the file — codex-router's approach for Grok, and the reason that
|
|
39
|
+
path has survived contact with a moving vendor while its bespoke Kimi
|
|
40
|
+
refresh has not.
|
|
41
|
+
|
|
42
|
+
The command is an argv **list**, dispatched with ``shell=False``. That is
|
|
43
|
+
the same trust decision v2.13.0 made for ``restart_command``: a string
|
|
44
|
+
that goes through a shell turns a config file into arbitrary code
|
|
45
|
+
execution, and half-executing one (``touch a; b`` under ``shell=False``
|
|
46
|
+
creates a file literally named ``a;`` and reports success) is worse than
|
|
47
|
+
refusing. Here there is no string form to refuse — the schema only
|
|
48
|
+
accepts a list.
|
|
49
|
+
|
|
50
|
+
Concurrency
|
|
51
|
+
-----------
|
|
52
|
+
Two workers hitting an expired token would otherwise both shell out.
|
|
53
|
+
Refresh is single-flighted in-process by a per-path lock, and serialised
|
|
54
|
+
across processes by an advisory ``flock`` on a sidecar file where the
|
|
55
|
+
platform has one. After acquiring either lock the file is re-read before
|
|
56
|
+
deciding to refresh, because the holder of the lock may already have done
|
|
57
|
+
the work.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
from __future__ import annotations
|
|
61
|
+
|
|
62
|
+
import json
|
|
63
|
+
import os
|
|
64
|
+
import subprocess
|
|
65
|
+
import threading
|
|
66
|
+
import time
|
|
67
|
+
from dataclasses import dataclass
|
|
68
|
+
from pathlib import Path
|
|
69
|
+
from typing import Any
|
|
70
|
+
|
|
71
|
+
from coderouter.logging import get_logger
|
|
72
|
+
from coderouter.secret_redaction import register_secret
|
|
73
|
+
|
|
74
|
+
__all__ = [
|
|
75
|
+
"CredentialError",
|
|
76
|
+
"resolve_provider_credential",
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
logger = get_logger(__name__)
|
|
80
|
+
|
|
81
|
+
# One lock per session file, created on demand. Keyed by the resolved
|
|
82
|
+
# absolute path so two providers pointing at the same file share it.
|
|
83
|
+
_locks: dict[str, threading.Lock] = {}
|
|
84
|
+
_locks_guard = threading.Lock()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class CredentialError(RuntimeError):
|
|
88
|
+
"""A credential was configured but could not be produced."""
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True)
|
|
92
|
+
class _SessionRead:
|
|
93
|
+
token: str | None
|
|
94
|
+
expires_at: float | None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _lock_for(path: str) -> threading.Lock:
|
|
98
|
+
with _locks_guard:
|
|
99
|
+
return _locks.setdefault(path, threading.Lock())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _dotted_get(payload: Any, dotted: str) -> Any:
|
|
103
|
+
"""Fetch ``a.b.c`` out of nested mappings.
|
|
104
|
+
|
|
105
|
+
Vendors nest differently (``access_token`` at the root for Kimi, under
|
|
106
|
+
``tokens`` for others), so the field is a path rather than a key. A
|
|
107
|
+
missing segment returns ``None`` rather than raising — an absent token
|
|
108
|
+
is a normal state (the operator has not logged in yet) and the caller
|
|
109
|
+
turns it into a clear message.
|
|
110
|
+
"""
|
|
111
|
+
current = payload
|
|
112
|
+
for segment in dotted.split("."):
|
|
113
|
+
if not isinstance(current, dict) or segment not in current:
|
|
114
|
+
return None
|
|
115
|
+
current = current[segment]
|
|
116
|
+
return current
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _as_epoch_seconds(value: Any) -> float | None:
|
|
120
|
+
"""Normalise an expiry field to epoch seconds.
|
|
121
|
+
|
|
122
|
+
Accepts seconds and milliseconds, because both are common and telling
|
|
123
|
+
them apart is unambiguous: any plausible expiry in seconds is under
|
|
124
|
+
1e11, and any in milliseconds is over it. A value we cannot read at
|
|
125
|
+
all yields ``None``, which is treated as "no expiry information" —
|
|
126
|
+
the token is used and the upstream 401 becomes the signal instead.
|
|
127
|
+
"""
|
|
128
|
+
if isinstance(value, bool) or value is None:
|
|
129
|
+
return None
|
|
130
|
+
if isinstance(value, (int, float)):
|
|
131
|
+
seconds = float(value)
|
|
132
|
+
return seconds / 1000.0 if seconds > 1e11 else seconds
|
|
133
|
+
if isinstance(value, str):
|
|
134
|
+
try:
|
|
135
|
+
return _as_epoch_seconds(float(value))
|
|
136
|
+
except ValueError:
|
|
137
|
+
return None
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _read_session(path: Path, *, field: str, expiry_field: str) -> _SessionRead:
|
|
142
|
+
"""Read the token (and its expiry, if present) out of a session file."""
|
|
143
|
+
try:
|
|
144
|
+
raw = path.read_text(encoding="utf-8")
|
|
145
|
+
except OSError as exc:
|
|
146
|
+
raise CredentialError(f"cannot read session file {path}: {exc}") from exc
|
|
147
|
+
try:
|
|
148
|
+
payload = json.loads(raw)
|
|
149
|
+
except json.JSONDecodeError as exc:
|
|
150
|
+
raise CredentialError(f"session file {path} is not valid JSON: {exc}") from exc
|
|
151
|
+
|
|
152
|
+
token = _dotted_get(payload, field)
|
|
153
|
+
if token is not None and not isinstance(token, str):
|
|
154
|
+
raise CredentialError(
|
|
155
|
+
f"session file {path}: field {field!r} is "
|
|
156
|
+
f"{type(token).__name__}, expected a string"
|
|
157
|
+
)
|
|
158
|
+
expires_at = _as_epoch_seconds(_dotted_get(payload, expiry_field))
|
|
159
|
+
return _SessionRead(token=token or None, expires_at=expires_at)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _needs_refresh(read: _SessionRead, *, early_ratio: float, min_lead_s: float) -> bool:
|
|
163
|
+
"""Whether to refresh now.
|
|
164
|
+
|
|
165
|
+
Refresh early rather than on expiry: a token that dies mid-request
|
|
166
|
+
costs a retry and a confusing upstream error. ``early_ratio`` is a
|
|
167
|
+
fraction of the *remaining* lifetime as seen right now, floored by
|
|
168
|
+
``min_lead_s`` — so a long-lived token still refreshes several minutes
|
|
169
|
+
before it lapses rather than at the last second.
|
|
170
|
+
"""
|
|
171
|
+
if read.token is None:
|
|
172
|
+
return True
|
|
173
|
+
if read.expires_at is None:
|
|
174
|
+
return False
|
|
175
|
+
remaining = read.expires_at - time.time()
|
|
176
|
+
return remaining <= max(min_lead_s, remaining * early_ratio)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _run_refresh(command: list[str], *, timeout_s: float, path: Path) -> None:
|
|
180
|
+
"""Run the vendor CLI so it rotates the token, then let the caller re-read.
|
|
181
|
+
|
|
182
|
+
``shell=False`` with an argv list: the config file names a program, it
|
|
183
|
+
does not get to compose a shell command. Failures are logged and
|
|
184
|
+
swallowed — a stale-but-present token is still worth trying, and the
|
|
185
|
+
upstream 401 is a better error for the operator than a refresh
|
|
186
|
+
traceback that hides it.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
completed = subprocess.run(
|
|
190
|
+
command,
|
|
191
|
+
shell=False,
|
|
192
|
+
capture_output=True,
|
|
193
|
+
timeout=timeout_s,
|
|
194
|
+
check=False,
|
|
195
|
+
)
|
|
196
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
197
|
+
logger.warning(
|
|
198
|
+
"credential-refresh-failed",
|
|
199
|
+
extra={"path": str(path), "command": command[0], "error": str(exc)},
|
|
200
|
+
)
|
|
201
|
+
return
|
|
202
|
+
if completed.returncode != 0:
|
|
203
|
+
# stderr is deliberately NOT logged: a failing auth CLI is exactly
|
|
204
|
+
# the thing most likely to print a token or a device code.
|
|
205
|
+
logger.warning(
|
|
206
|
+
"credential-refresh-nonzero",
|
|
207
|
+
extra={
|
|
208
|
+
"path": str(path),
|
|
209
|
+
"command": command[0],
|
|
210
|
+
"returncode": completed.returncode,
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _flock(handle: Any, *, exclusive: bool = True) -> Any:
|
|
216
|
+
"""Best-effort advisory lock; a no-op where the platform has none."""
|
|
217
|
+
try:
|
|
218
|
+
import fcntl
|
|
219
|
+
except ImportError: # pragma: no cover - Windows
|
|
220
|
+
return None
|
|
221
|
+
fcntl.flock(handle, fcntl.LOCK_EX if exclusive else fcntl.LOCK_SH)
|
|
222
|
+
return fcntl
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _resolve_cli_session(spec: Any, provider_name: str) -> str | None:
|
|
226
|
+
"""Read (and if needed refresh) a token a vendor CLI wrote to disk."""
|
|
227
|
+
path = Path(str(spec.path)).expanduser()
|
|
228
|
+
field = spec.field
|
|
229
|
+
expiry_field = spec.expiry_field
|
|
230
|
+
|
|
231
|
+
read = _read_session(path, field=field, expiry_field=expiry_field)
|
|
232
|
+
refresh = spec.refresh
|
|
233
|
+
if refresh is None or not _needs_refresh(
|
|
234
|
+
read, early_ratio=refresh.early_ratio, min_lead_s=refresh.min_lead_s
|
|
235
|
+
):
|
|
236
|
+
return _register(read.token, provider_name)
|
|
237
|
+
|
|
238
|
+
with _lock_for(str(path)):
|
|
239
|
+
# Re-read under the in-process lock: another worker may have
|
|
240
|
+
# refreshed while we waited.
|
|
241
|
+
read = _read_session(path, field=field, expiry_field=expiry_field)
|
|
242
|
+
if not _needs_refresh(
|
|
243
|
+
read, early_ratio=refresh.early_ratio, min_lead_s=refresh.min_lead_s
|
|
244
|
+
):
|
|
245
|
+
return _register(read.token, provider_name)
|
|
246
|
+
|
|
247
|
+
lock_path = path.with_name(path.name + ".coderouter-lock")
|
|
248
|
+
try:
|
|
249
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
250
|
+
with open(lock_path, "a+") as handle:
|
|
251
|
+
_flock(handle)
|
|
252
|
+
# And once more across processes, same reason.
|
|
253
|
+
read = _read_session(path, field=field, expiry_field=expiry_field)
|
|
254
|
+
if _needs_refresh(
|
|
255
|
+
read,
|
|
256
|
+
early_ratio=refresh.early_ratio,
|
|
257
|
+
min_lead_s=refresh.min_lead_s,
|
|
258
|
+
):
|
|
259
|
+
logger.info(
|
|
260
|
+
"credential-refresh-started",
|
|
261
|
+
extra={"provider": provider_name, "command": refresh.command[0]},
|
|
262
|
+
)
|
|
263
|
+
_run_refresh(
|
|
264
|
+
list(refresh.command), timeout_s=refresh.timeout_s, path=path
|
|
265
|
+
)
|
|
266
|
+
read = _read_session(path, field=field, expiry_field=expiry_field)
|
|
267
|
+
except OSError as exc:
|
|
268
|
+
logger.warning(
|
|
269
|
+
"credential-lock-failed",
|
|
270
|
+
extra={"path": str(lock_path), "error": str(exc)},
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
return _register(read.token, provider_name)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _register(token: str | None, provider_name: str) -> str | None:
|
|
277
|
+
"""Arm the log scrubber before the token goes anywhere near a header."""
|
|
278
|
+
if token:
|
|
279
|
+
register_secret(token, f"{provider_name}-session")
|
|
280
|
+
return token
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def resolve_provider_credential(config: Any) -> str | None:
|
|
284
|
+
"""Return the credential for ``config``, or ``None`` when there is none.
|
|
285
|
+
|
|
286
|
+
Order of precedence is deliberately not "try everything":
|
|
287
|
+
``credential`` and ``api_key_env`` are mutually exclusive at the
|
|
288
|
+
schema level, so exactly one path can apply. A resolver that silently
|
|
289
|
+
fell back between sources would make "which credential is this request
|
|
290
|
+
actually using?" unanswerable, which is the question an operator asks
|
|
291
|
+
at exactly the wrong moment.
|
|
292
|
+
"""
|
|
293
|
+
from coderouter.config.loader import resolve_api_key
|
|
294
|
+
|
|
295
|
+
spec = getattr(config, "credential", None)
|
|
296
|
+
if spec is None:
|
|
297
|
+
return resolve_api_key(getattr(config, "api_key_env", None))
|
|
298
|
+
|
|
299
|
+
name = str(getattr(config, "name", "?"))
|
|
300
|
+
if spec.source == "env":
|
|
301
|
+
return resolve_api_key(spec.env)
|
|
302
|
+
if spec.source == "cli_session":
|
|
303
|
+
try:
|
|
304
|
+
return _resolve_cli_session(spec, name)
|
|
305
|
+
except CredentialError as exc:
|
|
306
|
+
# A missing or malformed session file is an operator problem
|
|
307
|
+
# (not logged in yet, vendor changed its layout), not a crash.
|
|
308
|
+
# Returning None sends the request unauthenticated, the upstream
|
|
309
|
+
# 401 fires, and the fallback chain moves on.
|
|
310
|
+
logger.warning(
|
|
311
|
+
"credential-unavailable",
|
|
312
|
+
extra={"provider": name, "source": spec.source, "error": str(exc)},
|
|
313
|
+
)
|
|
314
|
+
return None
|
|
315
|
+
raise CredentialError(f"provider {name}: unknown credential source {spec.source!r}")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def session_path_is_sane(path: str) -> bool:
|
|
319
|
+
"""Reject a session path that reaches outside the user's home.
|
|
320
|
+
|
|
321
|
+
``providers.yaml`` is operator-owned config, at the same trust level as
|
|
322
|
+
``launcher.backends[*].binary`` — but v2.13.0 exists because that
|
|
323
|
+
assumption broke once already when a file could be picked up from the
|
|
324
|
+
working directory. A vendor CLI writes under ``$HOME``; nothing
|
|
325
|
+
legitimate needs ``/etc/shadow``.
|
|
326
|
+
"""
|
|
327
|
+
try:
|
|
328
|
+
resolved = Path(path).expanduser().resolve()
|
|
329
|
+
home = Path.home().resolve()
|
|
330
|
+
except (OSError, RuntimeError):
|
|
331
|
+
return False
|
|
332
|
+
return resolved == home or home in resolved.parents or os.name == "nt"
|
coderouter/doctor_apply.py
CHANGED
|
@@ -520,6 +520,34 @@ def _resolve_target_path(
|
|
|
520
520
|
return None
|
|
521
521
|
|
|
522
522
|
|
|
523
|
+
def _undo_partial_apply(
|
|
524
|
+
backups: dict[str, str], created_paths: list[Path]
|
|
525
|
+
) -> int:
|
|
526
|
+
"""Put back every file this invocation already rewrote.
|
|
527
|
+
|
|
528
|
+
Best-effort by design: a rollback that raises would mask the original
|
|
529
|
+
write failure, which is the thing the operator actually needs to see.
|
|
530
|
+
Files we created from nothing are removed; files we overwrote are
|
|
531
|
+
restored from the ``.bak`` copy taken moments earlier in the same loop.
|
|
532
|
+
|
|
533
|
+
Returns the number of files successfully reverted, for the error text.
|
|
534
|
+
"""
|
|
535
|
+
reverted = 0
|
|
536
|
+
for target_str, backup_str in backups.items():
|
|
537
|
+
try:
|
|
538
|
+
shutil.copy2(backup_str, target_str)
|
|
539
|
+
reverted += 1
|
|
540
|
+
except OSError:
|
|
541
|
+
continue
|
|
542
|
+
for created in created_paths:
|
|
543
|
+
try:
|
|
544
|
+
created.unlink(missing_ok=True)
|
|
545
|
+
reverted += 1
|
|
546
|
+
except OSError:
|
|
547
|
+
continue
|
|
548
|
+
return reverted
|
|
549
|
+
|
|
550
|
+
|
|
523
551
|
def apply_doctor_patches(
|
|
524
552
|
*,
|
|
525
553
|
report: Any, # DoctorReport — typed Any to avoid an import cycle
|
|
@@ -637,6 +665,9 @@ def apply_doctor_patches(
|
|
|
637
665
|
written = False
|
|
638
666
|
written_paths: list[str] = []
|
|
639
667
|
backups: dict[str, str] = {}
|
|
668
|
+
# Files this invocation created from nothing. They have no backup to
|
|
669
|
+
# restore from, so undoing them means deleting them again.
|
|
670
|
+
created_paths: list[Path] = []
|
|
640
671
|
for target_path in target_order:
|
|
641
672
|
doc, before_text = docs_by_path[target_path]
|
|
642
673
|
after_text = _dump_yaml_with_comments(doc)
|
|
@@ -656,15 +687,29 @@ def apply_doctor_patches(
|
|
|
656
687
|
reformat_only[str(target_path)] = diff_text
|
|
657
688
|
if write and target_changed:
|
|
658
689
|
# Backup first (only when we have a pre-existing file).
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
target_path.
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
690
|
+
try:
|
|
691
|
+
if target_path.is_file():
|
|
692
|
+
backup_path = target_path.with_suffix(
|
|
693
|
+
target_path.suffix + PROVIDERS_BACKUP_SUFFIX
|
|
694
|
+
)
|
|
695
|
+
shutil.copy2(target_path, backup_path)
|
|
696
|
+
backups[str(target_path)] = str(backup_path)
|
|
697
|
+
else:
|
|
698
|
+
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
699
|
+
created_paths.append(target_path)
|
|
700
|
+
target_path.write_text(after_text, encoding="utf-8")
|
|
701
|
+
except OSError as exc:
|
|
702
|
+
# v2.14.0: an apply that spans two files used to leave the
|
|
703
|
+
# first one rewritten when the second failed — a half-applied
|
|
704
|
+
# config the operator had no way to name, let alone undo. Roll
|
|
705
|
+
# the completed writes back before surfacing the error, so a
|
|
706
|
+
# failed apply is always a no-op rather than a partial one.
|
|
707
|
+
undone = _undo_partial_apply(backups, created_paths)
|
|
708
|
+
raise DoctorApplyError(
|
|
709
|
+
f"failed to write {target_path}: {exc}. "
|
|
710
|
+
f"Rolled back {undone} already-written file(s); "
|
|
711
|
+
"no partial apply was left on disk."
|
|
712
|
+
) from exc
|
|
668
713
|
written = True
|
|
669
714
|
written_paths.append(str(target_path))
|
|
670
715
|
|