coderouter-cli 2.13.0__py3-none-any.whl → 2.14.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 +4 -2
- coderouter/adapters/openai_compat.py +5 -2
- coderouter/cli.py +140 -2
- coderouter/config/loader.py +24 -1
- coderouter/config/schemas.py +144 -0
- coderouter/credentials.py +332 -0
- coderouter/doctor_apply.py +54 -9
- coderouter/ingress/dashboard_routes.py +24 -4
- coderouter/ingress/metrics_routes.py +13 -2
- coderouter/ingress/ui_auth.py +94 -0
- coderouter/logging.py +7 -0
- coderouter/rollback.py +217 -0
- coderouter/secret_redaction.py +606 -0
- coderouter/state/audit_log.py +6 -0
- coderouter/state/request_log.py +6 -0
- coderouter/vscode_init.py +156 -20
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/METADATA +1 -1
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/RECORD +21 -17
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/WHEEL +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/entry_points.txt +0 -0
- {coderouter_cli-2.13.0.dist-info → coderouter_cli-2.14.0.dist-info}/licenses/LICENSE +0 -0
|
@@ -45,7 +45,7 @@ from coderouter.adapters.base import (
|
|
|
45
45
|
ProviderCallOverrides,
|
|
46
46
|
StreamChunk,
|
|
47
47
|
)
|
|
48
|
-
from coderouter.
|
|
48
|
+
from coderouter.credentials import resolve_provider_credential
|
|
49
49
|
from coderouter.logging import get_logger, log_output_filter_applied
|
|
50
50
|
from coderouter.output_filters import OutputFilterChain
|
|
51
51
|
from coderouter.translation.anthropic import (
|
|
@@ -134,7 +134,9 @@ class AnthropicAdapter(BaseAdapter):
|
|
|
134
134
|
self.config.extra_body.get("anthropic_version", _DEFAULT_ANTHROPIC_VERSION)
|
|
135
135
|
),
|
|
136
136
|
}
|
|
137
|
-
|
|
137
|
+
# v2.14.0: see openai_compat._headers — same resolver, so an
|
|
138
|
+
# Anthropic-protocol provider can also ride a CLI session token.
|
|
139
|
+
api_key = resolve_provider_credential(self.config)
|
|
138
140
|
if api_key:
|
|
139
141
|
headers["x-api-key"] = api_key
|
|
140
142
|
# v0.4-D: forward the client's anthropic-beta header verbatim.
|
|
@@ -29,7 +29,7 @@ from coderouter.adapters.base import (
|
|
|
29
29
|
ProviderCallOverrides,
|
|
30
30
|
StreamChunk,
|
|
31
31
|
)
|
|
32
|
-
from coderouter.
|
|
32
|
+
from coderouter.credentials import resolve_provider_credential
|
|
33
33
|
from coderouter.logging import (
|
|
34
34
|
get_logger,
|
|
35
35
|
log_capability_degraded,
|
|
@@ -105,7 +105,10 @@ class OpenAICompatAdapter(BaseAdapter):
|
|
|
105
105
|
"Content-Type": "application/json",
|
|
106
106
|
"User-Agent": "CodeRouter/0.1",
|
|
107
107
|
}
|
|
108
|
-
|
|
108
|
+
# v2.14.0: resolve_provider_credential covers api_key_env (the
|
|
109
|
+
# historical path, unchanged) and credential.source=cli_session,
|
|
110
|
+
# which borrows the token a vendor CLI already wrote to disk.
|
|
111
|
+
api_key = resolve_provider_credential(self.config)
|
|
109
112
|
if api_key:
|
|
110
113
|
headers["Authorization"] = f"Bearer {api_key}"
|
|
111
114
|
return headers
|
coderouter/cli.py
CHANGED
|
@@ -149,6 +149,22 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
149
149
|
"See docs/guides/troubleshooting.md §5 for the threat model."
|
|
150
150
|
),
|
|
151
151
|
)
|
|
152
|
+
# v2.14.0: --check-secrets is the runtime counterpart to --check-env.
|
|
153
|
+
# --check-env asks "is the file holding my keys protected?"; this asks
|
|
154
|
+
# "does the running process know its own secrets, and did any of them
|
|
155
|
+
# already reach a log file?". The second question is the one that used
|
|
156
|
+
# to have no answer at all.
|
|
157
|
+
doctor.add_argument(
|
|
158
|
+
"--check-secrets",
|
|
159
|
+
action="store_true",
|
|
160
|
+
help=(
|
|
161
|
+
"Audit credential hygiene: prove the log-redaction filter "
|
|
162
|
+
"actually scrubs, report which declared api_key_env vars are "
|
|
163
|
+
"set, flag credentials pasted into a base_url, and scan the "
|
|
164
|
+
"already-written requests.jsonl / audit.jsonl under state_dir "
|
|
165
|
+
"for live key values. Exit 0 clean / 2 needs attention / 1 leak."
|
|
166
|
+
),
|
|
167
|
+
)
|
|
152
168
|
doctor.add_argument(
|
|
153
169
|
"--config",
|
|
154
170
|
default=None,
|
|
@@ -192,6 +208,55 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
192
208
|
),
|
|
193
209
|
)
|
|
194
210
|
|
|
211
|
+
# v2.14.0: `coderouter rollback` — the missing half of --apply.
|
|
212
|
+
# Both writers (doctor --apply, vscode-init) already dropped a `.bak`
|
|
213
|
+
# next to every file they rewrote; nothing could put it back. Restore
|
|
214
|
+
# is a swap, not an overwrite, so running it twice returns you to
|
|
215
|
+
# where you started rather than destroying the newer version.
|
|
216
|
+
rollback = sub.add_parser(
|
|
217
|
+
"rollback",
|
|
218
|
+
help="Restore files a previous --apply / vscode-init rewrote (v2.14.0).",
|
|
219
|
+
description=(
|
|
220
|
+
"Swap each managed file with its .bak sibling: providers.yaml, "
|
|
221
|
+
"~/.coderouter/model-capabilities.yaml, and (with --workspace) "
|
|
222
|
+
".vscode/settings.json and .envrc. The current contents become "
|
|
223
|
+
"the new .bak, so a second run toggles back. "
|
|
224
|
+
"Exit codes: 0 restored, 2 nothing to restore, 1 a restore failed."
|
|
225
|
+
),
|
|
226
|
+
)
|
|
227
|
+
rollback.add_argument(
|
|
228
|
+
"--config",
|
|
229
|
+
default=None,
|
|
230
|
+
help=(
|
|
231
|
+
"Path to providers.yaml. Defaults to the same file the loader "
|
|
232
|
+
"would read ($CODEROUTER_CONFIG, then ~/.coderouter/providers.yaml)."
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
rollback.add_argument(
|
|
236
|
+
"--workspace",
|
|
237
|
+
metavar="DIR",
|
|
238
|
+
default=None,
|
|
239
|
+
help=(
|
|
240
|
+
"Also restore the vscode-init outputs under DIR: "
|
|
241
|
+
"DIR/.vscode/settings.json and DIR/.envrc."
|
|
242
|
+
),
|
|
243
|
+
)
|
|
244
|
+
rollback.add_argument(
|
|
245
|
+
"--path",
|
|
246
|
+
action="append",
|
|
247
|
+
metavar="FILE",
|
|
248
|
+
default=None,
|
|
249
|
+
help=(
|
|
250
|
+
"Restore exactly this file from its .bak sibling. Repeatable. "
|
|
251
|
+
"When given, the default discovery set is not used."
|
|
252
|
+
),
|
|
253
|
+
)
|
|
254
|
+
rollback.add_argument(
|
|
255
|
+
"--dry-run",
|
|
256
|
+
action="store_true",
|
|
257
|
+
help="Report what would be restored without touching any file.",
|
|
258
|
+
)
|
|
259
|
+
|
|
195
260
|
# v1.5-C: `coderouter stats` — live TUI over GET /metrics.json.
|
|
196
261
|
# Lazy-imports ``curses`` inside the runner so the CLI boot stays
|
|
197
262
|
# snappy and environments without curses (rare, but e.g. minimal
|
|
@@ -490,6 +555,9 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
490
555
|
if args.command == "doctor":
|
|
491
556
|
return _run_doctor(args)
|
|
492
557
|
|
|
558
|
+
if args.command == "rollback":
|
|
559
|
+
return _run_rollback(args)
|
|
560
|
+
|
|
493
561
|
if args.command == "stats":
|
|
494
562
|
# v1.5-C: stats is intentionally a thin wrapper — all logic
|
|
495
563
|
# (fetch, render, curses loop) lives in coderouter.cli_stats so
|
|
@@ -526,9 +594,11 @@ def _run_doctor(args: argparse.Namespace) -> int:
|
|
|
526
594
|
worst-case of the two reports so CI guarding against both leak
|
|
527
595
|
risks AND broken providers can use a single command.
|
|
528
596
|
"""
|
|
529
|
-
|
|
597
|
+
check_secrets = bool(getattr(args, "check_secrets", False))
|
|
598
|
+
if args.check_model is None and args.check_env is None and not check_secrets:
|
|
530
599
|
print(
|
|
531
|
-
"doctor: provide --check-model PROVIDER
|
|
600
|
+
"doctor: provide --check-model PROVIDER, --check-env [PATH], "
|
|
601
|
+
"and/or --check-secrets",
|
|
532
602
|
file=sys.stderr,
|
|
533
603
|
)
|
|
534
604
|
return 1
|
|
@@ -541,6 +611,9 @@ def _run_doctor(args: argparse.Namespace) -> int:
|
|
|
541
611
|
if args.check_env is not None:
|
|
542
612
|
worst_exit = max(worst_exit, _run_check_env(args.check_env))
|
|
543
613
|
|
|
614
|
+
if check_secrets:
|
|
615
|
+
worst_exit = max(worst_exit, _run_check_secrets(args.config))
|
|
616
|
+
|
|
544
617
|
if args.check_model is not None:
|
|
545
618
|
worst_exit = max(worst_exit, _run_check_model(args))
|
|
546
619
|
|
|
@@ -969,5 +1042,70 @@ def _run_check_env(arg_value: str) -> int:
|
|
|
969
1042
|
return exit_code_for_env_security(report)
|
|
970
1043
|
|
|
971
1044
|
|
|
1045
|
+
def _run_rollback(args: argparse.Namespace) -> int:
|
|
1046
|
+
"""v2.14.0: put back what ``--apply`` / ``vscode-init`` overwrote.
|
|
1047
|
+
|
|
1048
|
+
Explicit ``--path`` wins over discovery so an operator can restore one
|
|
1049
|
+
file without also reverting an unrelated ``model-capabilities.yaml``
|
|
1050
|
+
edit from last week. Discovery resolves providers.yaml through the
|
|
1051
|
+
loader's own search order rather than a second copy of it.
|
|
1052
|
+
"""
|
|
1053
|
+
from coderouter.rollback import (
|
|
1054
|
+
discover_managed_files,
|
|
1055
|
+
exit_code_for_rollback,
|
|
1056
|
+
format_rollback_report,
|
|
1057
|
+
restore_many,
|
|
1058
|
+
)
|
|
1059
|
+
|
|
1060
|
+
explicit = getattr(args, "path", None)
|
|
1061
|
+
if explicit:
|
|
1062
|
+
targets: list[Path] = [Path(p).expanduser() for p in explicit]
|
|
1063
|
+
else:
|
|
1064
|
+
try:
|
|
1065
|
+
config_path: Path | None = _resolve_config_path(args.config)
|
|
1066
|
+
except Exception:
|
|
1067
|
+
# No config anywhere is not fatal here — the workspace files
|
|
1068
|
+
# and the user-layer capabilities file may still have backups.
|
|
1069
|
+
config_path = None
|
|
1070
|
+
targets = discover_managed_files(
|
|
1071
|
+
config_path=config_path,
|
|
1072
|
+
workspace=getattr(args, "workspace", None),
|
|
1073
|
+
)
|
|
1074
|
+
|
|
1075
|
+
outcomes = restore_many(targets, dry_run=bool(args.dry_run))
|
|
1076
|
+
print(format_rollback_report(outcomes, dry_run=bool(args.dry_run)))
|
|
1077
|
+
return exit_code_for_rollback(outcomes)
|
|
1078
|
+
|
|
1079
|
+
|
|
1080
|
+
def _run_check_secrets(config_path: str | None) -> int:
|
|
1081
|
+
"""v2.14.0: credential-hygiene audit for the running configuration.
|
|
1082
|
+
|
|
1083
|
+
Loads the config through the normal loader — which is also what arms
|
|
1084
|
+
the redaction registry — then runs the suite from
|
|
1085
|
+
:mod:`coderouter.secret_redaction`. A config that fails to load is a
|
|
1086
|
+
blocker (exit 1) rather than a skip: we cannot claim anything about
|
|
1087
|
+
secret hygiene for a file we could not read.
|
|
1088
|
+
"""
|
|
1089
|
+
from coderouter.config.loader import load_config
|
|
1090
|
+
from coderouter.secret_redaction import (
|
|
1091
|
+
check_secret_hygiene,
|
|
1092
|
+
exit_code_for_secret_report,
|
|
1093
|
+
format_secret_report,
|
|
1094
|
+
)
|
|
1095
|
+
|
|
1096
|
+
try:
|
|
1097
|
+
config = load_config(config_path)
|
|
1098
|
+
except FileNotFoundError as exc:
|
|
1099
|
+
print(f"doctor: {exc}", file=sys.stderr)
|
|
1100
|
+
return 1
|
|
1101
|
+
except Exception as exc:
|
|
1102
|
+
print(f"doctor: failed to load config: {exc}", file=sys.stderr)
|
|
1103
|
+
return 1
|
|
1104
|
+
|
|
1105
|
+
report = check_secret_hygiene(config)
|
|
1106
|
+
print(format_secret_report(report))
|
|
1107
|
+
return exit_code_for_secret_report(report)
|
|
1108
|
+
|
|
1109
|
+
|
|
972
1110
|
if __name__ == "__main__":
|
|
973
1111
|
sys.exit(main())
|
coderouter/config/loader.py
CHANGED
|
@@ -34,6 +34,7 @@ import yaml
|
|
|
34
34
|
|
|
35
35
|
from coderouter.config.schemas import CodeRouterConfig
|
|
36
36
|
from coderouter.logging import get_logger
|
|
37
|
+
from coderouter.secret_redaction import register_config_secrets, register_secret
|
|
37
38
|
|
|
38
39
|
logger = get_logger(__name__)
|
|
39
40
|
|
|
@@ -253,12 +254,34 @@ def load_config(path: str | os.PathLike[str] | None = None) -> CodeRouterConfig:
|
|
|
253
254
|
elif env_paid in {"0", "false", "no", "off"}:
|
|
254
255
|
config.allow_paid = False
|
|
255
256
|
|
|
257
|
+
# v2.14.0: arm log redaction before anything else runs. Registering here
|
|
258
|
+
# (rather than lazily on the first resolve_api_key call) means the
|
|
259
|
+
# startup log lines — which name providers and profiles — are already
|
|
260
|
+
# scrubbed, and so is any config-validation error raised downstream.
|
|
261
|
+
# Only labels are logged; the values never leave the registry.
|
|
262
|
+
registered = register_config_secrets(config)
|
|
263
|
+
if registered:
|
|
264
|
+
logger.info(
|
|
265
|
+
"secret-redaction-armed",
|
|
266
|
+
extra={"count": len(registered), "labels": sorted(set(registered))},
|
|
267
|
+
)
|
|
268
|
+
|
|
256
269
|
return config
|
|
257
270
|
|
|
258
271
|
|
|
259
272
|
def resolve_api_key(api_key_env: str | None) -> str | None:
|
|
260
|
-
"""Look up an API key from the named env var. Returns None if unset.
|
|
273
|
+
"""Look up an API key from the named env var. Returns None if unset.
|
|
274
|
+
|
|
275
|
+
v2.14.0: the resolved value is registered with
|
|
276
|
+
:mod:`coderouter.secret_redaction` before it is handed out. This is the
|
|
277
|
+
moment a credential enters the process, so it is the one place that can
|
|
278
|
+
guarantee the log scrubber knows about every key we actually use —
|
|
279
|
+
including keys for providers added by an adapter plugin, which never
|
|
280
|
+
pass through ``register_config_secrets``.
|
|
281
|
+
"""
|
|
261
282
|
if not api_key_env:
|
|
262
283
|
return None
|
|
263
284
|
value = os.environ.get(api_key_env, "").strip()
|
|
285
|
+
if value:
|
|
286
|
+
register_secret(value, api_key_env)
|
|
264
287
|
return value or None
|
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).
|