@crvouga/dev-fleet 0.0.0-stage → 0.1.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/.mise/locks/aws-cdk/2.1146.1/aube-lock.yaml +24 -0
- package/.mise/locks/aws-cdk/2.1146.1/package.json +7 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/pyproject.toml +5 -0
- package/.mise/locks/azure-cli/2.91.0~a837e014/uv.lock +2794 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/aube-lock.yaml +123 -0
- package/.mise/locks/gemini/0.66.0~84aac8e6/package.json +12 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/aube-lock.yaml +24 -0
- package/.mise/locks/npm-devcontainers-cli/0.90.0/package.json +7 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/aube-lock.yaml +1432 -0
- package/.mise/locks/npm-modelcontextprotocol-inspector/2.13.0~4c109bea/package.json +7 -0
- package/.mise/locks/npm-vercel/65.8.0/aube-lock.yaml +3595 -0
- package/.mise/locks/npm-vercel/65.8.0/package.json +7 -0
- package/.mise/locks/npm-wrangler/4.156.0/aube-lock.yaml +895 -0
- package/.mise/locks/npm-wrangler/4.156.0/package.json +7 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/aube-lock.yaml +24 -0
- package/.mise/locks/npm-yarnpkg-cli-dist/4.18.1/package.json +7 -0
- package/LICENSE +21 -0
- package/README.md +157 -2
- package/agents/antigravity-proxy.ts +3 -0
- package/agents/audit.md +89 -0
- package/agents/claude-proxy +5 -0
- package/agents/claude-proxy.ts +3 -0
- package/agents/codex-proxy.ts +3 -0
- package/agents/grok-proxy.ts +3 -0
- package/agents/managed-proxy.ts +55 -0
- package/agents/opencode-proxy.ts +3 -0
- package/agents/registry.json +209 -0
- package/agents/registry.schema.json +149 -0
- package/agents/setup.md +128 -0
- package/agents/t3.json +40 -0
- package/ansible/macos/manager.yml +30 -0
- package/ansible/macos/policy.json +17 -0
- package/ansible/monitoring/README.md +154 -0
- package/ansible/monitoring/agent-darwin.yml +198 -0
- package/ansible/monitoring/agent.yml +134 -0
- package/ansible/monitoring/beszel-agent.service +42 -0
- package/ansible/monitoring/compose.yml +74 -0
- package/ansible/monitoring/hub-darwin.yml +102 -0
- package/ansible/monitoring/lock.json +11 -0
- package/ansible/net/remote-probe.sh +13 -0
- package/ansible/ubuntu/authorize-sudo.sh +51 -0
- package/ansible/ubuntu/baseline.yml +89 -0
- package/ansible/ubuntu/disk-guard.py +104 -0
- package/ansible/ubuntu/policy.json +43 -0
- package/ansible/ubuntu/recovery.yml +15 -0
- package/ansible/ubuntu/t3/policy.json +7 -0
- package/ansible/ubuntu/t3-desktop.yml +220 -0
- package/ansible/ubuntu/t3-health.sh +33 -0
- package/ansible/ubuntu/t3-policy.yml +76 -0
- package/ansible/ubuntu/t3-route.yml +28 -0
- package/ansible/ubuntu/tailscale.yml +75 -0
- package/ansible/ubuntu/tasks/credentials.yml +47 -0
- package/ansible/ubuntu/tasks/docker.yml +31 -0
- package/ansible/ubuntu/tasks/packages.yml +56 -0
- package/ansible/ubuntu/tasks/power.yml +51 -0
- package/ansible/ubuntu/tasks/recovery.yml +39 -0
- package/ansible/ubuntu/tasks/resources.yml +252 -0
- package/ansible/ubuntu/tasks/services.yml +31 -0
- package/ansible/ubuntu/tasks/ssh.yml +71 -0
- package/ansible/ubuntu/tasks/user.yml +120 -0
- package/bin/devfleet.mjs +36 -0
- package/bootstrap/bootstrap.ps1 +50 -0
- package/bootstrap/bootstrap.sh +73 -0
- package/bootstrap/devfleet +7 -0
- package/bootstrap/docker-desktop.sh +33 -0
- package/bootstrap/doctor.ts +189 -0
- package/bootstrap/fleet-manager-run.sh +76 -0
- package/bootstrap/fleet-remote-bootstrap.sh +185 -0
- package/bootstrap/global-config.ts +113 -0
- package/bootstrap/install-launcher.sh +7 -0
- package/bootstrap/native-aws.ts +86 -0
- package/bootstrap/native-t3.ts +154 -0
- package/bootstrap/npm.ts +350 -0
- package/bootstrap/setup-status.ts +51 -0
- package/bootstrap/setup.ts +88 -0
- package/bootstrap/shell-path.ts +105 -0
- package/bootstrap/verify-t3-archive.py +49 -0
- package/bootstrap/worker-auth.ts +65 -0
- package/bootstrap/worker-github.ts +47 -0
- package/bootstrap/worker-tools.ts +24 -0
- package/cliproxy/AGENTS.md +11 -0
- package/cliproxy/README.md +269 -0
- package/cliproxy/clients/bootstrap.py +143 -0
- package/cliproxy/clients/launcher.py +57 -0
- package/cliproxy/compose.yaml +90 -0
- package/cliproxy/config.template.yaml +70 -0
- package/cliproxy/docs/cloud-adoption.md +16 -0
- package/cliproxy/docs/deployment.png +0 -0
- package/cliproxy/docs/deployment.svg +109 -0
- package/cliproxy/docs/health-workflow.example.yml +185 -0
- package/cliproxy/docs/migration.md +131 -0
- package/cliproxy/docs/model-health-report.schema.json +364 -0
- package/cliproxy/docs/model-health.md +393 -0
- package/cliproxy/image.lock.json +5 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup-check.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-backup.plist +48 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-fleet.plist +32 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-gc.plist +38 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-monitor.plist +29 -0
- package/cliproxy/launchd/dev.devfleet.cliproxy-vault-renew.plist +31 -0
- package/cliproxy/models.json +1618 -0
- package/cliproxy/requirements.lock +78 -0
- package/cliproxy/scripts/accounts.py +38 -0
- package/cliproxy/scripts/audit.py +123 -0
- package/cliproxy/scripts/backup +234 -0
- package/cliproxy/scripts/check.py +34 -0
- package/cliproxy/scripts/checkpoint.py +226 -0
- package/cliproxy/scripts/cli.py +64 -0
- package/cliproxy/scripts/cliproxy_host.py +257 -0
- package/cliproxy/scripts/cloud_state.py +150 -0
- package/cliproxy/scripts/container-health +6 -0
- package/cliproxy/scripts/docker +24 -0
- package/cliproxy/scripts/external-health.py +79 -0
- package/cliproxy/scripts/fleet_config.py +43 -0
- package/cliproxy/scripts/fleet_sync.py +274 -0
- package/cliproxy/scripts/initialize +84 -0
- package/cliproxy/scripts/install-dependencies +48 -0
- package/cliproxy/scripts/install.py +406 -0
- package/cliproxy/scripts/model_health.py +16 -0
- package/cliproxy/scripts/modelhealth/__init__.py +5 -0
- package/cliproxy/scripts/modelhealth/checks.py +429 -0
- package/cliproxy/scripts/modelhealth/cli.py +117 -0
- package/cliproxy/scripts/modelhealth/config.py +186 -0
- package/cliproxy/scripts/modelhealth/emulator.py +536 -0
- package/cliproxy/scripts/modelhealth/expect.py +88 -0
- package/cliproxy/scripts/modelhealth/findings.py +183 -0
- package/cliproxy/scripts/modelhealth/protocol.py +330 -0
- package/cliproxy/scripts/modelhealth/redact.py +28 -0
- package/cliproxy/scripts/modelhealth/report.py +150 -0
- package/cliproxy/scripts/modelhealth/runner.py +390 -0
- package/cliproxy/scripts/modelhealth/scenario.py +131 -0
- package/cliproxy/scripts/modelhealth/schema.py +63 -0
- package/cliproxy/scripts/modelhealth/simulate.py +111 -0
- package/cliproxy/scripts/modelhealth/transport.py +213 -0
- package/cliproxy/scripts/monitor +68 -0
- package/cliproxy/scripts/ops +527 -0
- package/cliproxy/scripts/public-endpoint +127 -0
- package/cliproxy/scripts/recover_node.py +177 -0
- package/cliproxy/scripts/restore-verify +60 -0
- package/cliproxy/scripts/retention.py +144 -0
- package/cliproxy/scripts/service-start +7 -0
- package/cliproxy/scripts/validate-config +56 -0
- package/cliproxy/scripts/vault-access.py +57 -0
- package/cliproxy/scripts/vault-backup-login +55 -0
- package/cliproxy/scripts/vault-recovery +56 -0
- package/cliproxy/scripts/vault-renew +30 -0
- package/cliproxy/systemd/cliproxy-backup-check.service +10 -0
- package/cliproxy/systemd/cliproxy-backup-check.timer +8 -0
- package/cliproxy/systemd/cliproxy-backup.service +13 -0
- package/cliproxy/systemd/cliproxy-backup.timer +8 -0
- package/cliproxy/systemd/cliproxy-fleet.service +17 -0
- package/cliproxy/systemd/cliproxy-gc.service +13 -0
- package/cliproxy/systemd/cliproxy-gc.timer +8 -0
- package/cliproxy/systemd/cliproxy-monitor.service +8 -0
- package/cliproxy/systemd/cliproxy-monitor.timer +7 -0
- package/cliproxy/systemd/cliproxy-vault-renew.service +8 -0
- package/cliproxy/systemd/cliproxy-vault-renew.timer +7 -0
- package/cliproxy/vendor/LICENSE +22 -0
- package/cliproxy/vendor/config.upstream.yaml +1349 -0
- package/examples/fleet.json +95 -0
- package/examples/roster.json +13 -0
- package/mise.lock +1492 -0
- package/mise.toml +99 -0
- package/package.json +65 -4
- package/schema/fleet.schema.json +364 -0
- package/schema/roster.schema.json +77 -0
- package/src/agents/account-relay.ts +111 -0
- package/src/agents/accounts.ts +85 -0
- package/src/agents/audit.ts +77 -0
- package/src/agents/cli.ts +13 -0
- package/src/agents/command.ts +204 -0
- package/src/agents/converge.ts +123 -0
- package/src/agents/doctor.ts +107 -0
- package/src/agents/errors.ts +2 -0
- package/src/agents/files.ts +73 -0
- package/src/agents/fleet-verify.ts +156 -0
- package/src/agents/launch.ts +290 -0
- package/src/agents/native-verify.ts +226 -0
- package/src/agents/provider-errors.ts +50 -0
- package/src/agents/registry.ts +55 -0
- package/src/agents/render.ts +110 -0
- package/src/agents/round-trip.ts +192 -0
- package/src/agents/secrets.ts +13 -0
- package/src/agents/toolchain.ts +34 -0
- package/src/agents/verify-cache.ts +103 -0
- package/src/agents/verify.ts +127 -0
- package/src/auth-checks.ts +163 -0
- package/src/cli/agent-guide.ts +95 -0
- package/src/cli/app.ts +115 -0
- package/src/cli/cli.ts +21 -0
- package/src/cli/command-index.ts +50 -0
- package/src/cli/contracts.ts +25 -0
- package/src/cli/fleet-reference.ts +17 -0
- package/src/cli/fleet-sync-checkout.ts +91 -0
- package/src/cli/fleet-sync-enrollment.ts +144 -0
- package/src/cli/fleet-sync-forward.ts +134 -0
- package/src/cli/fleet-sync-manager.ts +193 -0
- package/src/cli/fleet-sync-monitoring.ts +185 -0
- package/src/cli/fleet-sync-output.ts +314 -0
- package/src/cli/fleet-sync-privilege.ts +27 -0
- package/src/cli/fleet-sync-progress.ts +109 -0
- package/src/cli/fleet-sync-remote.ts +236 -0
- package/src/cli/fleet-sync-report.ts +26 -0
- package/src/cli/fleet-sync-schedule.ts +138 -0
- package/src/cli/fleet-sync-vault.ts +166 -0
- package/src/cli/fleet-sync.ts +309 -0
- package/src/cli/fleet.ts +163 -0
- package/src/cli/managed-health.ts +219 -0
- package/src/cli/node-deployment.ts +34 -0
- package/src/cli/node-sync.ts +114 -0
- package/src/cli/node-t3.ts +31 -0
- package/src/cli/options.ts +141 -0
- package/src/cli/probe-source.ts +7 -0
- package/src/cli/program.ts +169 -0
- package/src/cli/register-fleet.ts +246 -0
- package/src/cli/registry.ts +158 -0
- package/src/cli/repair-apply.ts +67 -0
- package/src/cli/repair-guard.ts +11 -0
- package/src/cli/repair-inspect.ts +150 -0
- package/src/cli/repair-script.ts +76 -0
- package/src/cli/repair.ts +209 -0
- package/src/cli/roster.ts +89 -0
- package/src/cli/search.ts +35 -0
- package/src/cli/settings.ts +64 -0
- package/src/cli/source-operations.ts +78 -0
- package/src/cli/t3/command.ts +90 -0
- package/src/cli/t3/register.ts +55 -0
- package/src/cli/tailnet.ts +127 -0
- package/src/cli/ui-actions.ts +133 -0
- package/src/cli/ui-controller.ts +113 -0
- package/src/cli/ui-data.ts +106 -0
- package/src/cli/ui-execute.ts +163 -0
- package/src/cli/ui-repairs.ts +34 -0
- package/src/cli/ui-reports.ts +211 -0
- package/src/cli/ui-targets.ts +100 -0
- package/src/cli/ui-terminal.ts +109 -0
- package/src/cli/ui-types.ts +49 -0
- package/src/cli/ui-wizard.ts +101 -0
- package/src/cli/ui.ts +9 -0
- package/src/cli/worker-health-output.ts +37 -0
- package/src/config.ts +186 -0
- package/src/configure.ts +255 -0
- package/src/definition.ts +260 -0
- package/src/diagnostics.ts +92 -0
- package/src/doctor-check.ts +10 -0
- package/src/errors.ts +1 -0
- package/src/fingerprint.ts +120 -0
- package/src/fleet-config.ts +88 -0
- package/src/index.ts +6 -0
- package/src/input-hashes.ts +73 -0
- package/src/machine/local-source.ts +52 -0
- package/src/machine/lock-error.ts +2 -0
- package/src/machine/platform.ts +44 -0
- package/src/machine/source.ts +68 -0
- package/src/machine/state.ts +185 -0
- package/src/machine-health.ts +95 -0
- package/src/managed-source.ts +47 -0
- package/src/manager-failure.ts +48 -0
- package/src/manager-sync.ts +400 -0
- package/src/manager.ts +62 -0
- package/src/monitoring/apply.ts +171 -0
- package/src/monitoring/beszel.ts +191 -0
- package/src/monitoring/enroll.ts +110 -0
- package/src/monitoring/hub-access.ts +113 -0
- package/src/monitoring/hub-viewer.ts +142 -0
- package/src/monitoring/hub.ts +93 -0
- package/src/monitoring/manager-agent.ts +73 -0
- package/src/monitoring/manager-hub.ts +232 -0
- package/src/monitoring/remote.ts +65 -0
- package/src/network-definition.ts +83 -0
- package/src/node-converge-linux.ts +372 -0
- package/src/node-converge-shared.ts +83 -0
- package/src/node-converge.ts +40 -0
- package/src/onboard.ts +35 -0
- package/src/platform/ansible-receipt.ts +128 -0
- package/src/platform/ansible.ts +62 -0
- package/src/platform/darwin.ts +203 -0
- package/src/platform/detect.ts +11 -0
- package/src/platform/fallback.ts +110 -0
- package/src/platform/index.ts +38 -0
- package/src/platform/launchd.ts +98 -0
- package/src/platform/linux.ts +139 -0
- package/src/platform/ps.ts +45 -0
- package/src/platform/resource-verify.ts +128 -0
- package/src/platform/systemd-user.ts +95 -0
- package/src/platform/t3-policy.ts +137 -0
- package/src/platform/t3-route.ts +68 -0
- package/src/platform/t3-service.ts +75 -0
- package/src/platform/types.ts +123 -0
- package/src/platform/ubuntu-audit.ts +63 -0
- package/src/platform/ubuntu-verify.ts +105 -0
- package/src/platform/windows.ts +148 -0
- package/src/power-recovery.ts +55 -0
- package/src/probe.ts +51 -0
- package/src/protocol.ts +119 -0
- package/src/proxy-converge.ts +85 -0
- package/src/remote-bootstrap.ts +212 -0
- package/src/remote-entry.ts +89 -0
- package/src/remote-lock.ts +12 -0
- package/src/repair-catalog.ts +206 -0
- package/src/repair-plan.ts +78 -0
- package/src/repair-runner.ts +84 -0
- package/src/report.ts +71 -0
- package/src/runner.ts +104 -0
- package/src/secrets.ts +169 -0
- package/src/service-auth.ts +115 -0
- package/src/ssh.ts +135 -0
- package/src/sync.ts +104 -0
- package/src/t3/accounts.ts +81 -0
- package/src/t3/desired.ts +165 -0
- package/src/t3/files.ts +81 -0
- package/src/t3/names-check.ts +53 -0
- package/src/t3/names.ts +155 -0
- package/src/t3/plan.ts +82 -0
- package/src/t3/providers.ts +74 -0
- package/src/t3/reconcile.ts +123 -0
- package/src/t3-endpoint.ts +48 -0
- package/src/t3-linked-check.ts +101 -0
- package/src/tailnet-health.ts +231 -0
- package/src/tailnet-matching.ts +37 -0
- package/src/tailscale.ts +141 -0
- package/src/test-fixtures.ts +36 -0
- package/src/toolchain-checks.ts +83 -0
- package/src/topology.ts +24 -0
- package/src/transport.ts +122 -0
- package/src/types.ts +95 -0
- package/src/vault-config.ts +38 -0
- package/src/worker-access-checks.ts +115 -0
- package/src/worker-capacity.ts +34 -0
- package/src/worker-host-checks.ts +176 -0
- package/src/worker-policy.ts +64 -0
- package/src/worker-readiness.ts +64 -0
- package/src/workstation-checks.ts +29 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Protocol-faithful local emulator of the proxy's OpenAI surface.
|
|
2
|
+
|
|
3
|
+
It is deterministic and credential-free. It implements Chat Completions and
|
|
4
|
+
Responses (streaming and not), tool calls, structured output, OpenAI error
|
|
5
|
+
envelopes, and the proxy's multi-account routing policy (fill-first or round
|
|
6
|
+
robin, session affinity, cooldown after quota or transient failures, bounded
|
|
7
|
+
credential retries), with a
|
|
8
|
+
virtual clock and injectable faults. It is a test double for the harness and
|
|
9
|
+
its contracts; it makes no claim about real provider or Cursor behavior.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
import threading
|
|
16
|
+
import time
|
|
17
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
18
|
+
|
|
19
|
+
TOKEN = re.compile(r"[A-Z]{3}-[0-9]{4}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _words(text):
|
|
23
|
+
return len(str(text).split())
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class Account:
|
|
27
|
+
def __init__(self, ident, models=None, quota=None):
|
|
28
|
+
self.id = ident
|
|
29
|
+
self.models = set(models) if models is not None else None
|
|
30
|
+
self.quota = quota # remaining requests; None is unlimited, 0 is exhausted
|
|
31
|
+
self.fail_next = 0 # transient upstream failures still to emit
|
|
32
|
+
self.cooldown_until = 0.0
|
|
33
|
+
self.served = 0
|
|
34
|
+
|
|
35
|
+
def serves(self, model_id):
|
|
36
|
+
return self.models is None or model_id in self.models
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Emulator:
|
|
40
|
+
def __init__(self, model_ids, keys=("emulator-client-key-0000000000000000",), accounts=None,
|
|
41
|
+
cooldown_s=30.0, transient_cooldown_s=60.0, max_retry_credentials=0, chunk_delay_s=0.0,
|
|
42
|
+
strategy="fill-first"):
|
|
43
|
+
self.model_ids = list(model_ids)
|
|
44
|
+
self.keys = set(keys)
|
|
45
|
+
self.open_api = False # fault: accepts anonymous callers
|
|
46
|
+
self.accounts = accounts or [Account("acct-a"), Account("acct-b")]
|
|
47
|
+
self.cooldown_s = cooldown_s
|
|
48
|
+
self.transient_cooldown_s = transient_cooldown_s # upstream's documented 60s transient-error cooldown
|
|
49
|
+
self.strategy = strategy
|
|
50
|
+
self.max_retry_credentials = max_retry_credentials
|
|
51
|
+
self.chunk_delay_s = chunk_delay_s
|
|
52
|
+
self.faults = {}
|
|
53
|
+
self.offset = 0.0
|
|
54
|
+
self.lock = threading.Lock()
|
|
55
|
+
self.pointer = {}
|
|
56
|
+
self.affinity = {}
|
|
57
|
+
self.routing_log = []
|
|
58
|
+
self.requests = 0
|
|
59
|
+
self.server = None
|
|
60
|
+
|
|
61
|
+
# ---------------------------------------------------------------- control
|
|
62
|
+
def now(self):
|
|
63
|
+
return time.monotonic() + self.offset
|
|
64
|
+
|
|
65
|
+
def advance(self, seconds):
|
|
66
|
+
with self.lock:
|
|
67
|
+
self.offset += seconds
|
|
68
|
+
|
|
69
|
+
def fault(self, **faults):
|
|
70
|
+
with self.lock:
|
|
71
|
+
self.faults.update(faults)
|
|
72
|
+
|
|
73
|
+
def clear_faults(self):
|
|
74
|
+
with self.lock:
|
|
75
|
+
self.faults = {}
|
|
76
|
+
|
|
77
|
+
def account(self, ident):
|
|
78
|
+
return next(a for a in self.accounts if a.id == ident)
|
|
79
|
+
|
|
80
|
+
def start(self):
|
|
81
|
+
emulator = self
|
|
82
|
+
|
|
83
|
+
class Handler(_Handler):
|
|
84
|
+
owner = emulator
|
|
85
|
+
|
|
86
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
87
|
+
self.server.daemon_threads = True
|
|
88
|
+
threading.Thread(target=self.server.serve_forever, args=(0.02,), daemon=True).start()
|
|
89
|
+
return "http://127.0.0.1:%d/v1" % self.server.server_address[1]
|
|
90
|
+
|
|
91
|
+
def stop(self):
|
|
92
|
+
if self.server:
|
|
93
|
+
self.server.shutdown()
|
|
94
|
+
self.server.server_close()
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def key(self):
|
|
98
|
+
return sorted(self.keys)[0]
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------- routing
|
|
101
|
+
def route(self, model_id, affinity):
|
|
102
|
+
"""Return (account, error) following the documented routing policy.
|
|
103
|
+
|
|
104
|
+
fill-first: accounts are tried in declaration order, so one account takes all traffic until
|
|
105
|
+
it is exhausted (quota) or failing (transient), then the next. round-robin rotates instead.
|
|
106
|
+
A session bound to an available account stays on it (an established binding outranks order);
|
|
107
|
+
when that account is unavailable the session is rebound to the failover account.
|
|
108
|
+
max_retry_credentials of 0 means every eligible credential may be tried.
|
|
109
|
+
"""
|
|
110
|
+
with self.lock:
|
|
111
|
+
now = self.now()
|
|
112
|
+
candidates = [a for a in self.accounts if a.serves(model_id)]
|
|
113
|
+
tried = []
|
|
114
|
+
if not candidates:
|
|
115
|
+
self.routing_log.append({"model": model_id, "tried": [], "served_by": None})
|
|
116
|
+
return None, (404, "model_not_found", "No account serves this model.", None)
|
|
117
|
+
if self.strategy == "round-robin":
|
|
118
|
+
start = self.pointer.get(model_id, 0)
|
|
119
|
+
order = [candidates[(start + i) % len(candidates)] for i in range(len(candidates))]
|
|
120
|
+
else:
|
|
121
|
+
order = list(candidates)
|
|
122
|
+
sticky = self.affinity.get((model_id, affinity))
|
|
123
|
+
if sticky is not None:
|
|
124
|
+
order = [a for a in order if a.id == sticky] + [a for a in order if a.id != sticky]
|
|
125
|
+
last, attempts = None, 0
|
|
126
|
+
for account in order:
|
|
127
|
+
if account.cooldown_until > now:
|
|
128
|
+
tried.append((account.id, "cooldown"))
|
|
129
|
+
continue
|
|
130
|
+
if self.max_retry_credentials and attempts >= self.max_retry_credentials:
|
|
131
|
+
break
|
|
132
|
+
attempts += 1
|
|
133
|
+
if account.quota is not None and account.quota <= 0:
|
|
134
|
+
account.cooldown_until = now + self.cooldown_s
|
|
135
|
+
tried.append((account.id, "quota"))
|
|
136
|
+
last = (429, "rate_limit_exceeded", "Account quota exhausted.", self.cooldown_s)
|
|
137
|
+
continue
|
|
138
|
+
if account.fail_next > 0:
|
|
139
|
+
account.fail_next -= 1
|
|
140
|
+
account.cooldown_until = now + self.transient_cooldown_s
|
|
141
|
+
tried.append((account.id, "upstream_error"))
|
|
142
|
+
last = (503, "upstream_unavailable", "Upstream provider is temporarily unavailable.", None)
|
|
143
|
+
continue
|
|
144
|
+
if account.quota is not None:
|
|
145
|
+
account.quota -= 1
|
|
146
|
+
account.served += 1
|
|
147
|
+
self.pointer[model_id] = (candidates.index(account) + 1) % len(candidates)
|
|
148
|
+
self.affinity[(model_id, affinity)] = account.id
|
|
149
|
+
tried.append((account.id, "served"))
|
|
150
|
+
self.routing_log.append({"model": model_id, "tried": tried, "served_by": account.id})
|
|
151
|
+
return account, None
|
|
152
|
+
if last is None:
|
|
153
|
+
waits = [a.cooldown_until - now for a in candidates if a.cooldown_until > now]
|
|
154
|
+
last = (429, "rate_limit_exceeded", "All credentials are cooling down.", max(waits or [1.0]))
|
|
155
|
+
self.routing_log.append({"model": model_id, "tried": tried, "served_by": None})
|
|
156
|
+
return None, last
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# --------------------------------------------------------------------- brain
|
|
160
|
+
|
|
161
|
+
def _text_of(content):
|
|
162
|
+
if isinstance(content, str):
|
|
163
|
+
return content
|
|
164
|
+
if isinstance(content, list):
|
|
165
|
+
return " ".join(str(p.get("text", "")) for p in content if isinstance(p, dict))
|
|
166
|
+
return ""
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _conversation(protocol, body):
|
|
170
|
+
"""Flatten either protocol into [(role, text)], tool results and tool defs."""
|
|
171
|
+
turns, results, system = [], [], []
|
|
172
|
+
if protocol == "chat":
|
|
173
|
+
for message in body.get("messages") or []:
|
|
174
|
+
role, text = message.get("role"), _text_of(message.get("content"))
|
|
175
|
+
if role in ("system", "developer"):
|
|
176
|
+
system.append(text)
|
|
177
|
+
elif role == "tool":
|
|
178
|
+
results.append(text)
|
|
179
|
+
turns.append(("tool", text))
|
|
180
|
+
else:
|
|
181
|
+
turns.append((role, text))
|
|
182
|
+
tools = [t["function"] for t in body.get("tools") or []]
|
|
183
|
+
else:
|
|
184
|
+
if body.get("instructions"):
|
|
185
|
+
system.append(body["instructions"])
|
|
186
|
+
items = body.get("input")
|
|
187
|
+
items = [{"role": "user", "content": items}] if isinstance(items, str) else (items or [])
|
|
188
|
+
for item in items:
|
|
189
|
+
if item.get("type") == "function_call_output":
|
|
190
|
+
results.append(str(item.get("output")))
|
|
191
|
+
turns.append(("tool", str(item.get("output"))))
|
|
192
|
+
elif item.get("type") == "function_call":
|
|
193
|
+
continue
|
|
194
|
+
elif item.get("role") in ("system", "developer"):
|
|
195
|
+
system.append(_text_of(item.get("content")))
|
|
196
|
+
else:
|
|
197
|
+
turns.append((item.get("role"), _text_of(item.get("content"))))
|
|
198
|
+
tools = [t for t in body.get("tools") or []]
|
|
199
|
+
return turns, results, system, tools
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _forced(protocol, body):
|
|
203
|
+
choice = body.get("tool_choice")
|
|
204
|
+
if isinstance(choice, dict):
|
|
205
|
+
return (choice.get("function") or choice).get("name")
|
|
206
|
+
return choice if choice in ("required", "none", "auto") else None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _argument(name, spec, text, markers, tool_name, last_result):
|
|
210
|
+
if "enum" in spec:
|
|
211
|
+
for value in spec["enum"]:
|
|
212
|
+
if str(value).lower() in text.lower():
|
|
213
|
+
return value
|
|
214
|
+
return spec["enum"][0]
|
|
215
|
+
if spec.get("type") == "string":
|
|
216
|
+
if spec.get("pattern") and TOKEN.search(text):
|
|
217
|
+
return TOKEN.search(text).group(0)
|
|
218
|
+
if name == "path":
|
|
219
|
+
files = re.findall(r"[\w./-]+\.py", text)
|
|
220
|
+
tests = [f for f in files if f.startswith("test_")]
|
|
221
|
+
sources = [f for f in files if not f.startswith("test_")]
|
|
222
|
+
chosen = (tests if tool_name == "run_tests" else sources) or files
|
|
223
|
+
return chosen[0] if chosen else "main.py"
|
|
224
|
+
if name == "content":
|
|
225
|
+
return (last_result or "").replace(" - ", " + ") or "pass"
|
|
226
|
+
return markers[0] if markers else "value"
|
|
227
|
+
if spec.get("type") in ("integer", "number"):
|
|
228
|
+
return 1
|
|
229
|
+
if spec.get("type") == "boolean":
|
|
230
|
+
return True
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _arguments(tool, text, markers, index, last_result):
|
|
235
|
+
schema = tool.get("parameters") or {}
|
|
236
|
+
out = {}
|
|
237
|
+
for name, spec in (schema.get("properties") or {}).items():
|
|
238
|
+
if name in schema.get("required", []) or True:
|
|
239
|
+
local = markers[index:index + 1] or markers[:1]
|
|
240
|
+
out[name] = _argument(name, spec, text, local, tool["name"], last_result)
|
|
241
|
+
return out
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _json_value(spec, text="", name=""):
|
|
245
|
+
if "const" in spec:
|
|
246
|
+
return spec["const"]
|
|
247
|
+
kind = spec.get("type")
|
|
248
|
+
if kind == "object":
|
|
249
|
+
return {k: _json_value(v, text, k) for k, v in spec.get("properties", {}).items()}
|
|
250
|
+
if kind == "array":
|
|
251
|
+
return [_json_value(spec.get("items", {}), text, name)]
|
|
252
|
+
if kind in ("integer", "number"):
|
|
253
|
+
return 2.8 if kind == "number" else 1
|
|
254
|
+
if kind == "boolean":
|
|
255
|
+
return True
|
|
256
|
+
if kind == "string":
|
|
257
|
+
stated = re.search(r"\b%s set to ([A-Za-z0-9_.-]+)" % re.escape(name), text) if name else None
|
|
258
|
+
return stated.group(1) if stated else spec.get("enum", ["x"])[0]
|
|
259
|
+
return None
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def plan(protocol, body):
|
|
263
|
+
"""Decide the model's reply: {'text', 'calls', 'finish'}; deterministic."""
|
|
264
|
+
turns, results, system, tools = _conversation(protocol, body)
|
|
265
|
+
user_text = " ".join(t for r, t in turns if r == "user")
|
|
266
|
+
last_user = next((t for r, t in reversed(turns) if r == "user"), "")
|
|
267
|
+
forced = _forced(protocol, body)
|
|
268
|
+
limit = body.get("max_tokens") or body.get("max_output_tokens") or 1000
|
|
269
|
+
by_name = {t["name"]: t for t in tools}
|
|
270
|
+
if tools and forced not in (None, "none", "auto") and not (forced == "required" and not tools):
|
|
271
|
+
names = [forced] if forced in by_name else [tools[0]["name"]]
|
|
272
|
+
markers = re.findall(r"\[\[(.+?)\]\]", last_user)
|
|
273
|
+
count = max(1, len(markers)) if forced == "required" else 1
|
|
274
|
+
calls = [{"name": names[0], "arguments": _arguments(by_name[names[0]], last_user, markers, i, results[-1] if results else None)}
|
|
275
|
+
for i in range(count)]
|
|
276
|
+
return {"text": "", "calls": calls, "finish": "tool_calls"}
|
|
277
|
+
schema = None
|
|
278
|
+
if protocol == "chat" and (body.get("response_format") or {}).get("type") == "json_schema":
|
|
279
|
+
schema = body["response_format"]["json_schema"]["schema"]
|
|
280
|
+
if protocol == "responses" and ((body.get("text") or {}).get("format") or {}).get("type") == "json_schema":
|
|
281
|
+
schema = body["text"]["format"]["schema"]
|
|
282
|
+
if schema is not None:
|
|
283
|
+
text = json.dumps(_json_value(schema, last_user))
|
|
284
|
+
elif results and turns and turns[-1][0] == "tool":
|
|
285
|
+
text = "Done. " + " ".join(results)
|
|
286
|
+
elif "code word" in last_user.lower() and TOKEN.search(user_text):
|
|
287
|
+
text = TOKEN.search(user_text).group(0)
|
|
288
|
+
elif any(re.search(r"exact marker", s) for s in system):
|
|
289
|
+
text = "Hello. " + TOKEN.search(" ".join(system)).group(0)
|
|
290
|
+
elif re.search(r"[Cc]ount from 1 to (\d+)", last_user):
|
|
291
|
+
text = "\n".join(str(i) for i in range(1, int(re.search(r"to (\d+)", last_user).group(1)) + 1))
|
|
292
|
+
elif re.search(r"numbers from 1 to (\d+)", last_user):
|
|
293
|
+
text = " ".join(str(i) for i in range(1, int(re.search(r"to (\d+)", last_user).group(1)) + 1))
|
|
294
|
+
else:
|
|
295
|
+
text = "OK"
|
|
296
|
+
words = text.split(" ") if "\n" not in text else text.split("\n")
|
|
297
|
+
finish = "stop"
|
|
298
|
+
if len(words) > limit:
|
|
299
|
+
text, finish = (" " if "\n" not in text else "\n").join(words[:limit]), "length"
|
|
300
|
+
return {"text": text, "calls": [], "finish": finish}
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _usage_counts(body, reply):
|
|
304
|
+
prompt = _words(json.dumps(body.get("messages") or body.get("input") or ""))
|
|
305
|
+
completion = _words(reply["text"]) + sum(_words(json.dumps(c["arguments"])) for c in reply["calls"])
|
|
306
|
+
return prompt, max(1, completion)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _call_id(seed, index, prefix):
|
|
310
|
+
return prefix + hashlib.sha1(("%s-%d" % (seed, index)).encode()).hexdigest()[:12]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
# ------------------------------------------------------------------ renderers
|
|
314
|
+
|
|
315
|
+
def chat_body(model_id, body, reply, request_id, faults):
|
|
316
|
+
prompt, completion = _usage_counts(body, reply)
|
|
317
|
+
message = {"role": "assistant", "content": reply["text"] or None}
|
|
318
|
+
if reply["calls"]:
|
|
319
|
+
message["tool_calls"] = [
|
|
320
|
+
{"id": None if faults.get("drop_tool_ids") else _call_id(request_id, i, "call_"), "type": "function",
|
|
321
|
+
"function": {"name": c["name"], "arguments": "{broken" if faults.get("bad_json_args") else json.dumps(c["arguments"])}}
|
|
322
|
+
for i, c in enumerate(reply["calls"])]
|
|
323
|
+
out = {"id": "chatcmpl-" + request_id, "object": "chat.completion", "created": 1_700_000_000, "model": model_id,
|
|
324
|
+
"choices": [{"index": 0, "message": message, "finish_reason": faults.get("finish_reason", reply["finish"])}]}
|
|
325
|
+
if not faults.get("drop_usage"):
|
|
326
|
+
out["usage"] = {"prompt_tokens": prompt, "completion_tokens": completion,
|
|
327
|
+
"total_tokens": prompt + completion + (1 if faults.get("bad_usage") else 0)}
|
|
328
|
+
return out
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def chat_events(model_id, body, reply, request_id, faults):
|
|
332
|
+
base = {"id": "chatcmpl-" + request_id, "object": "chat.completion.chunk", "created": 1_700_000_000, "model": model_id}
|
|
333
|
+
|
|
334
|
+
def chunk(delta, finish=None, usage=None, choices=True):
|
|
335
|
+
data = dict(base)
|
|
336
|
+
data["choices"] = [{"index": 0, "delta": delta, "finish_reason": finish}] if choices else []
|
|
337
|
+
if usage:
|
|
338
|
+
data["usage"] = usage
|
|
339
|
+
return (None, json.dumps(data))
|
|
340
|
+
|
|
341
|
+
yield chunk({"role": "assistant", "content": ""})
|
|
342
|
+
pieces = reply["text"].split(" ") if "\n" not in reply["text"] else reply["text"].split("\n")
|
|
343
|
+
glue = " " if "\n" not in reply["text"] else "\n"
|
|
344
|
+
for i, piece in enumerate(pieces if reply["text"] else []):
|
|
345
|
+
yield chunk({"content": piece + (glue if i < len(pieces) - 1 else "")})
|
|
346
|
+
for index, call in enumerate(reply["calls"]):
|
|
347
|
+
arguments = json.dumps(call["arguments"])
|
|
348
|
+
yield chunk({"tool_calls": [{"index": index, "id": _call_id(request_id, index, "call_"), "type": "function",
|
|
349
|
+
"function": {"name": call["name"], "arguments": ""}}]})
|
|
350
|
+
for start in range(0, len(arguments), 7):
|
|
351
|
+
yield chunk({"tool_calls": [{"index": index, "function": {"arguments": arguments[start:start + 7]}}]})
|
|
352
|
+
yield chunk({}, finish=faults.get("finish_reason", reply["finish"]))
|
|
353
|
+
if body.get("stream_options", {}).get("include_usage"):
|
|
354
|
+
prompt, completion = _usage_counts(body, reply)
|
|
355
|
+
yield chunk({}, usage={"prompt_tokens": prompt, "completion_tokens": completion,
|
|
356
|
+
"total_tokens": prompt + completion}, choices=False)
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def responses_object(model_id, body, reply, request_id, faults, status="completed"):
|
|
360
|
+
output = []
|
|
361
|
+
if reply["text"]:
|
|
362
|
+
output.append({"type": "message", "id": "msg_" + request_id, "role": "assistant", "status": "completed",
|
|
363
|
+
"content": [{"type": "output_text", "text": reply["text"], "annotations": []}]})
|
|
364
|
+
for i, call in enumerate(reply["calls"]):
|
|
365
|
+
output.append({"type": "function_call", "id": "fc_%s%d" % (request_id, i), "call_id": _call_id(request_id, i, "call_"),
|
|
366
|
+
"name": call["name"], "arguments": json.dumps(call["arguments"]), "status": "completed"})
|
|
367
|
+
prompt, completion = _usage_counts(body, reply)
|
|
368
|
+
out = {"id": "resp_" + request_id, "object": "response", "created_at": 1_700_000_000, "model": model_id,
|
|
369
|
+
"status": "incomplete" if reply["finish"] == "length" else status, "output": output}
|
|
370
|
+
if reply["finish"] == "length":
|
|
371
|
+
out["incomplete_details"] = {"reason": "max_output_tokens"}
|
|
372
|
+
if not faults.get("drop_usage"):
|
|
373
|
+
out["usage"] = {"input_tokens": prompt, "output_tokens": completion, "total_tokens": prompt + completion}
|
|
374
|
+
return out
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def responses_events(model_id, body, reply, request_id, faults):
|
|
378
|
+
sequence = [0]
|
|
379
|
+
|
|
380
|
+
def event(kind, **data):
|
|
381
|
+
data.update(type=kind, sequence_number=sequence[0])
|
|
382
|
+
sequence[0] += 1
|
|
383
|
+
return (kind, json.dumps(data))
|
|
384
|
+
|
|
385
|
+
final = responses_object(model_id, body, reply, request_id, faults)
|
|
386
|
+
shell = dict(final, status="in_progress", output=[])
|
|
387
|
+
yield event("response.created", response=shell)
|
|
388
|
+
yield event("response.in_progress", response=shell)
|
|
389
|
+
for index, item in enumerate(final["output"]):
|
|
390
|
+
yield event("response.output_item.added", output_index=index, item=dict(item, status="in_progress"))
|
|
391
|
+
if item["type"] == "message":
|
|
392
|
+
yield event("response.content_part.added", output_index=index, content_index=0, item_id=item["id"],
|
|
393
|
+
part={"type": "output_text", "text": "", "annotations": []})
|
|
394
|
+
words = reply["text"].split(" ") if "\n" not in reply["text"] else reply["text"].split("\n")
|
|
395
|
+
glue = " " if "\n" not in reply["text"] else "\n"
|
|
396
|
+
for i, piece in enumerate(words):
|
|
397
|
+
yield event("response.output_text.delta", output_index=index, content_index=0, item_id=item["id"],
|
|
398
|
+
delta=piece + (glue if i < len(words) - 1 else ""))
|
|
399
|
+
yield event("response.output_text.done", output_index=index, content_index=0, item_id=item["id"], text=reply["text"])
|
|
400
|
+
else:
|
|
401
|
+
arguments = item["arguments"]
|
|
402
|
+
for start in range(0, len(arguments), 7):
|
|
403
|
+
yield event("response.function_call_arguments.delta", output_index=index, item_id=item["id"],
|
|
404
|
+
delta=arguments[start:start + 7])
|
|
405
|
+
yield event("response.function_call_arguments.done", output_index=index, item_id=item["id"], arguments=arguments)
|
|
406
|
+
yield event("response.output_item.done", output_index=index, item=item)
|
|
407
|
+
yield event("response.incomplete" if final["status"] == "incomplete" else "response.completed", response=final)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# --------------------------------------------------------------------- server
|
|
411
|
+
|
|
412
|
+
class _Handler(BaseHTTPRequestHandler):
|
|
413
|
+
owner = None
|
|
414
|
+
protocol_version = "HTTP/1.0"
|
|
415
|
+
|
|
416
|
+
def log_message(self, *args):
|
|
417
|
+
pass
|
|
418
|
+
|
|
419
|
+
def _send(self, status, body, headers=None, content_type="application/json"):
|
|
420
|
+
data = body if isinstance(body, bytes) else json.dumps(body).encode()
|
|
421
|
+
self.send_response(status)
|
|
422
|
+
self.send_header("Content-Type", content_type)
|
|
423
|
+
self.send_header("Content-Length", str(len(data)))
|
|
424
|
+
for key, value in (headers or {}).items():
|
|
425
|
+
self.send_header(key, str(value))
|
|
426
|
+
self.end_headers()
|
|
427
|
+
self.wfile.write(data)
|
|
428
|
+
|
|
429
|
+
def _error(self, status, code, message, retry_after=None, headers=None):
|
|
430
|
+
headers = dict(headers or {})
|
|
431
|
+
if retry_after:
|
|
432
|
+
headers["Retry-After"] = str(int(retry_after))
|
|
433
|
+
kind = "invalid_request_error" if status < 500 else "server_error"
|
|
434
|
+
if status == 401:
|
|
435
|
+
kind = "authentication_error"
|
|
436
|
+
elif status == 429:
|
|
437
|
+
kind = "rate_limit_error"
|
|
438
|
+
self._send(status, {"error": {"message": message, "type": kind, "param": None, "code": code}}, headers)
|
|
439
|
+
|
|
440
|
+
def _authorized(self):
|
|
441
|
+
if self.owner.open_api:
|
|
442
|
+
return True
|
|
443
|
+
auth = self.headers.get("Authorization", "")
|
|
444
|
+
return auth.startswith("Bearer ") and auth[7:] in self.owner.keys
|
|
445
|
+
|
|
446
|
+
def do_GET(self):
|
|
447
|
+
owner = self.owner
|
|
448
|
+
if self.path == "/management.html":
|
|
449
|
+
return self._send(200, b"<html>dashboard</html>", content_type="text/html")
|
|
450
|
+
if self.path == "/v1/models":
|
|
451
|
+
if not self._authorized():
|
|
452
|
+
return self._error(401, "invalid_api_key", "Missing or invalid API key.")
|
|
453
|
+
missing = owner.faults.get("missing_models", ())
|
|
454
|
+
data = [{"id": a, "object": "model", "created": 1_700_000_000, "owned_by": "cli-proxy"}
|
|
455
|
+
for a in owner.model_ids if a not in missing]
|
|
456
|
+
return self._send(200, {"object": "list", "data": data})
|
|
457
|
+
self._error(404, "not_found", "Unknown path.")
|
|
458
|
+
|
|
459
|
+
def do_POST(self):
|
|
460
|
+
owner = self.owner
|
|
461
|
+
owner.requests += 1
|
|
462
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
463
|
+
raw = self.rfile.read(length)
|
|
464
|
+
if not self._authorized():
|
|
465
|
+
return self._error(401, "invalid_api_key", "Missing or invalid API key.")
|
|
466
|
+
protocol = {"/v1/chat/completions": "chat", "/v1/responses": "responses"}.get(self.path)
|
|
467
|
+
if protocol is None:
|
|
468
|
+
return self._error(404, "not_found", "Unknown path.")
|
|
469
|
+
try:
|
|
470
|
+
body = json.loads(raw)
|
|
471
|
+
assert isinstance(body, dict)
|
|
472
|
+
except (ValueError, AssertionError):
|
|
473
|
+
return self._error(400, "invalid_json", "Request body is not valid JSON.")
|
|
474
|
+
faults = dict(owner.faults)
|
|
475
|
+
override = faults.get("status")
|
|
476
|
+
if override and faults.get("status_remaining", 1) > 0:
|
|
477
|
+
with owner.lock:
|
|
478
|
+
owner.faults["status_remaining"] = faults.get("status_remaining", 1) - 1
|
|
479
|
+
return self._error(override, "injected_fault", "Injected fault.", faults.get("retry_after"))
|
|
480
|
+
required = "messages" if protocol == "chat" else "input"
|
|
481
|
+
model_id = body.get("model")
|
|
482
|
+
if required not in body:
|
|
483
|
+
return self._error(400, "missing_required_parameter", "Missing required parameter: '%s'." % required)
|
|
484
|
+
if model_id not in owner.model_ids or model_id in faults.get("missing_models", ()):
|
|
485
|
+
return self._error(404, "model_not_found", "The model does not exist.")
|
|
486
|
+
failing = faults.get("model_status", {}).get(model_id)
|
|
487
|
+
if failing:
|
|
488
|
+
return self._error(failing, "injected_fault", "Injected fault.", faults.get("retry_after"))
|
|
489
|
+
affinity = self.headers.get("X-Session-Id") or body.get("user") or hashlib.sha1(
|
|
490
|
+
json.dumps(body.get("messages") or body.get("input"), sort_keys=True).encode()).hexdigest()[:8]
|
|
491
|
+
account, failure = owner.route(model_id, affinity)
|
|
492
|
+
if failure:
|
|
493
|
+
status, code, message, retry_after = failure
|
|
494
|
+
return self._error(status, code, message, retry_after)
|
|
495
|
+
headers = {"X-Emulator-Account": account.id}
|
|
496
|
+
request_id = hashlib.sha1(str(owner.requests).encode()).hexdigest()[:16]
|
|
497
|
+
reply = plan(protocol, body)
|
|
498
|
+
if faults.get("shared_token"): # simulates cross-request contamination
|
|
499
|
+
for call in reply["calls"]:
|
|
500
|
+
call["arguments"] = {k: ("AAA-0000" if isinstance(v, str) and TOKEN.fullmatch(v) else v)
|
|
501
|
+
for k, v in call["arguments"].items()}
|
|
502
|
+
if faults.get("stall_s"):
|
|
503
|
+
time.sleep(faults["stall_s"])
|
|
504
|
+
if not body.get("stream"):
|
|
505
|
+
if protocol == "chat":
|
|
506
|
+
return self._send(200, chat_body(model_id, body, reply, request_id, faults), headers)
|
|
507
|
+
return self._send(200, responses_object(model_id, body, reply, request_id, faults), headers)
|
|
508
|
+
self._stream(protocol, model_id, body, reply, request_id, faults, headers)
|
|
509
|
+
|
|
510
|
+
def _stream(self, protocol, model_id, body, reply, request_id, faults, headers):
|
|
511
|
+
owner = self.owner
|
|
512
|
+
self.send_response(200)
|
|
513
|
+
self.send_header("Content-Type", "text/event-stream")
|
|
514
|
+
self.send_header("Cache-Control", "no-cache")
|
|
515
|
+
for key, value in headers.items():
|
|
516
|
+
self.send_header(key, value)
|
|
517
|
+
self.end_headers()
|
|
518
|
+
events = (chat_events if protocol == "chat" else responses_events)(model_id, body, reply, request_id, faults)
|
|
519
|
+
try:
|
|
520
|
+
if faults.get("first_token_delay_s"):
|
|
521
|
+
time.sleep(faults["first_token_delay_s"])
|
|
522
|
+
for count, (name, data) in enumerate(events):
|
|
523
|
+
if faults.get("truncate_after") is not None and count >= faults["truncate_after"]:
|
|
524
|
+
return # connection closes with no terminal event or [DONE]
|
|
525
|
+
if faults.get("corrupt_frame") == count:
|
|
526
|
+
self.wfile.write(b"this is not an sse field\n\n")
|
|
527
|
+
frame = ("event: %s\n" % name if name else "") + "data: " + data + "\n\n"
|
|
528
|
+
self.wfile.write(frame.encode())
|
|
529
|
+
self.wfile.flush()
|
|
530
|
+
if owner.chunk_delay_s:
|
|
531
|
+
time.sleep(owner.chunk_delay_s)
|
|
532
|
+
if protocol == "chat" and not faults.get("omit_done"):
|
|
533
|
+
self.wfile.write(b"data: [DONE]\n\n")
|
|
534
|
+
self.wfile.flush()
|
|
535
|
+
except (BrokenPipeError, ConnectionResetError):
|
|
536
|
+
return # client cancelled
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
"""Evaluate declarative expectations against a normalized response."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import re
|
|
5
|
+
|
|
6
|
+
from . import schema
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _lower(text):
|
|
10
|
+
return text.lower()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _matches(actual, wanted):
|
|
14
|
+
if isinstance(wanted, dict):
|
|
15
|
+
if "regex" in wanted:
|
|
16
|
+
return isinstance(actual, str) and re.search(wanted["regex"], actual) is not None
|
|
17
|
+
if "min" in wanted or "max" in wanted:
|
|
18
|
+
return isinstance(actual, (int, float)) and wanted.get("min", actual) <= actual <= wanted.get("max", actual)
|
|
19
|
+
if isinstance(actual, str) and isinstance(wanted, str):
|
|
20
|
+
return actual.casefold() == wanted.casefold()
|
|
21
|
+
return actual == wanted
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def evaluate(expect, norm, tool_schemas=None, max_gap=None):
|
|
25
|
+
"""Return a list of (category, message); empty means every expectation held."""
|
|
26
|
+
found = [("protocol", message) for message in norm.violations]
|
|
27
|
+
if norm.upstream_error is not None:
|
|
28
|
+
found.append(("upstream", "provider error reported inside a successful response"))
|
|
29
|
+
reasons = expect.get("finish_reason")
|
|
30
|
+
if reasons and norm.finish_reason not in reasons:
|
|
31
|
+
found.append(("assertion", "finish_reason %r not in %s" % (norm.finish_reason, reasons)))
|
|
32
|
+
content = expect.get("content")
|
|
33
|
+
if content:
|
|
34
|
+
if content.get("non_empty") and not norm.content.strip():
|
|
35
|
+
found.append(("assertion", "assistant content is empty"))
|
|
36
|
+
needle = content.get("contains")
|
|
37
|
+
if needle and _lower(needle) not in _lower(norm.content):
|
|
38
|
+
found.append(("assertion", "assistant content does not contain the expected token"))
|
|
39
|
+
if content.get("max_chars") and len(norm.content) > content["max_chars"]:
|
|
40
|
+
found.append(("assertion", "assistant content exceeds max_chars"))
|
|
41
|
+
if expect.get("usage") and norm.usage is None:
|
|
42
|
+
found.append(("protocol", "usage metadata is missing"))
|
|
43
|
+
stream = expect.get("stream")
|
|
44
|
+
if stream:
|
|
45
|
+
if norm.events < stream.get("min_events", 1):
|
|
46
|
+
found.append(("assertion", "stream carried only %d events" % norm.events))
|
|
47
|
+
if stream.get("max_gap_s") and max_gap is not None and max_gap > stream["max_gap_s"]:
|
|
48
|
+
found.append(("assertion", "stream stalled %.1fs between events" % max_gap))
|
|
49
|
+
if "json" in expect:
|
|
50
|
+
try:
|
|
51
|
+
document = json.loads(norm.content)
|
|
52
|
+
except ValueError:
|
|
53
|
+
found.append(("assertion", "content is not valid JSON"))
|
|
54
|
+
else:
|
|
55
|
+
found += [("assertion", "json " + e) for e in schema.errors(document, expect["json"]["schema"])]
|
|
56
|
+
wanted = expect.get("tool_calls")
|
|
57
|
+
if wanted is not None:
|
|
58
|
+
found += _tool_calls(wanted, norm, tool_schemas or {})
|
|
59
|
+
return found
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _tool_calls(wanted, norm, tool_schemas):
|
|
63
|
+
calls = norm.tool_calls
|
|
64
|
+
found = []
|
|
65
|
+
if len(calls) < wanted.get("min", 0):
|
|
66
|
+
found.append(("assertion", "expected at least %d tool calls, got %d" % (wanted["min"], len(calls))))
|
|
67
|
+
if "max" in wanted and len(calls) > wanted["max"]:
|
|
68
|
+
found.append(("assertion", "expected at most %d tool calls, got %d" % (wanted["max"], len(calls))))
|
|
69
|
+
if wanted.get("unique_ids") and len({c["id"] for c in calls}) != len(calls):
|
|
70
|
+
found.append(("protocol", "tool call ids are not unique"))
|
|
71
|
+
for call in calls:
|
|
72
|
+
if wanted.get("names") and call["name"] not in wanted["names"]:
|
|
73
|
+
found.append(("assertion", "unexpected tool name %r" % call["name"]))
|
|
74
|
+
continue
|
|
75
|
+
if call["args"] is None:
|
|
76
|
+
continue # already reported as a protocol violation
|
|
77
|
+
if wanted.get("arguments_schema") == "from_tools" and call["name"] in tool_schemas:
|
|
78
|
+
found += [("assertion", "arguments " + e) for e in schema.errors(call["args"], tool_schemas[call["name"]])]
|
|
79
|
+
for field, value in (wanted.get("arguments") or {}).items():
|
|
80
|
+
if calls and not any(c["args"] and _matches(c["args"].get(field), value) for c in calls):
|
|
81
|
+
found.append(("assertion", "no tool call carried the expected %r argument" % field))
|
|
82
|
+
values = wanted.get("values")
|
|
83
|
+
if values:
|
|
84
|
+
seen = {c["args"].get(values["field"]) for c in calls if c["args"]}
|
|
85
|
+
missing = [v for v in values["include"] if v not in seen]
|
|
86
|
+
if missing:
|
|
87
|
+
found.append(("assertion", "tool calls missed expected %r values" % values["field"]))
|
|
88
|
+
return found
|