diff --git a/BarWidget.qml b/BarWidget.qml index 14b690d..3dc9ebb 100644 --- a/BarWidget.qml +++ b/BarWidget.qml @@ -18,6 +18,21 @@ Panel { property var quotaData: ({accounts: []}) property var auth: ({}) property var preferences: ({}) + property var routingSettings: ({values: {}, capabilities: {}, strategies: []}) + property var diagnostics: ({}) + property var customProviders: [] + property bool providerWeightsSupported: false + property bool showingDiagnostics: false + property bool showingRouting: false + property bool pageRefreshPending: false + property string editingProvider: "" + property string originalProviderUrl: "" + property string originalProviderModels: "" + property string removingProvider: "" + property var clientKeys: [] + property bool showingClientKeys: false + property string revokingClient: "" + readonly property bool quotaAlerts: setting("quotaAlerts", false) === true readonly property bool showExtraLimits: setting("showExtraLimits", false) === true property var revealedEmails: ({}) property string notice: "" @@ -39,9 +54,10 @@ Panel { implicitHeight: button.implicitHeight function refresh() { if (!poll.running) poll.running = true } + function refreshActivePage() { pageRefreshPending = page === 1 || page === 2 } function refreshQuotas(force) { if (!quotaPoll.running && snapshot.running) { - quotaPoll.command = ["python3", "-B", helper, "quotas"].concat(force ? ["--force"] : []) + quotaPoll.command = ["python3", "-B", helper, "quotas"].concat(force ? ["--force"] : []).concat(quotaAlerts ? ["--notify"] : []) quotaPoll.running = true } } @@ -52,6 +68,11 @@ Panel { clipboard.command = ["python3", "-B", helper, "copy", kind] clipboard.running = true } + function copyClientKey(name) { + if (clipboard.running) return + clipboard.command = ["python3", "-B", helper, "client-copy", name] + clipboard.running = true + } function perform(args, payload) { if (busy) return if (args[0].indexOf("auth-") === 0) authRevision++ @@ -75,8 +96,15 @@ Panel { } } if (result.preferences) preferences = result.preferences + if (result.routing_settings) routingSettings = result.routing_settings + if (result.diagnostics) diagnostics = result.diagnostics + if (result.custom_providers) customProviders = result.custom_providers + if (result.custom_provider) { addingKey = false; editingProvider = ""; providerKey.text = "" } + if (result.client_keys) clientKeys = result.client_keys + if (result.provider_weights_supported !== undefined) providerWeightsSupported = result.provider_weights_supported if (result.logs !== undefined) logText = result.logs if (result.message) notice = result.message + if (result.alerts && result.alerts.error) { noticeError = true; notice = result.alerts.error } } function setDisplaySetting(name, value) { var next = Object.assign({}, settings) @@ -85,6 +113,62 @@ Panel { if (bar && bar.shell) bar.shell.updateEntryInline(moduleName, next) } function accountLabel(a) { return a.email || a.label || a.name || "Account" } + function diagnosticSummary(section) { + if (!section || !section.availability) return "Not loaded" + return section.availability + (section.retained === undefined ? "" : " · " + section.retained + " shown · " + (section.invalid || 0) + " unrecognized · " + (section.omitted || 0) + " omitted") + } + function diagnosticEventDetails(event) { + var parts = [] + if (event.timestamp) parts.push(event.timestamp) + if (event.provider) parts.push(event.provider) + if (event.model_label) parts.push(event.model_label) + if (typeof event.status_code === "number") parts.push("HTTP " + event.status_code) + if (typeof event.ttft_ms === "number") parts.push("First token " + event.ttft_ms + " ms") + Object.keys(event.tokens || {}).forEach(function(field) { parts.push(field.replace(/_/g, " ") + ": " + event.tokens[field]) }) + return parts.join(" · ") + } + function editProvider(provider) { + editingProvider = provider.name + providerName.text = provider.name + providerUrl.text = provider.url + providerModels.text = JSON.stringify(provider.models) + originalProviderUrl = providerUrl.text + originalProviderModels = providerModels.text + providerKey.text = "" + providerWeight.text = "" + providerCredential.text = "0" + addingKey = true + } + function newProvider() { + editingProvider = "" + providerName.text = "" + providerUrl.text = "" + providerModels.text = "" + providerKey.text = "" + providerWeight.text = "" + providerCredential.text = "0" + addingKey = true + } + function saveProvider() { + try { + var models = providerModels.text.trim() + var payload = {name: providerName.text, key: providerKey.text} + if (!editingProvider || providerUrl.text !== originalProviderUrl) payload.url = providerUrl.text + if (!editingProvider || providerModels.text !== originalProviderModels) payload.models = models[0] === "[" ? JSON.parse(models) : models + if (providerKey.text !== "" || providerWeight.text.trim() !== "") payload.credential_index = Number(providerCredential.text) + if (providerWeight.text.trim() !== "") payload.weight = Number(providerWeight.text) + perform(["custom-save"], payload) + providerKey.text = "" + } catch (e) { noticeError = true; notice = "Enter model IDs or a valid JSON model list." } + } + function saveRetryLimits() { + if (![retryRounds.text, retryCredentials.text, retryWait.text].every(function(value) { return /^[0-9]+$/.test(value.trim()) })) { + noticeError = true + notice = "Enter a whole number in each retry field." + return + } + perform(["routing-save"], {"request-retry": Number(retryRounds.text), "max-retry-credentials": Number(retryCredentials.text), "max-retry-interval": Number(retryWait.text)}) + } function toggleEmail(name) { var next = Object.assign({}, revealedEmails) next[name] = !next[name] @@ -120,10 +204,22 @@ Panel { } onOpenedChanged: { - if (opened) { refresh(); refreshQuotas(false); if (snapshot.configured && !authPoll.running) authPoll.running = true } - else revealedEmails = ({}) + if (opened) { + refresh(); refreshQuotas(false) + if (snapshot.configured && !authPoll.running) authPoll.running = true + refreshActivePage() + } else { + revealedEmails = ({}); removingProvider = ""; revokingClient = "" + providerKey.text = ""; callback.text = ""; addingKey = false; editingProvider = "" + pageRefreshPending = false + } + } + onPageChanged: { + scroll.contentY = 0 + removingProvider = "" + revokingClient = "" + refreshActivePage() } - onPageChanged: { scroll.contentY = 0; if (page === 2 && snapshot.running) perform(["preferences"]) } Component.onCompleted: refresh() IpcHandler { @@ -154,6 +250,7 @@ Panel { if (result.quotas) root.quotaData = result.quotas var newNames = (result.accounts || []).map(function(a) { return a.name }).join("|") if (root.opened && result.running && (!wasRunning || oldNames !== newNames)) root.refreshQuotas(false) + if (root.opened && result.running && !wasRunning) root.refreshActivePage() } else root.receive(result) } catch (e) { root.notice = "Unable to read proxy status."; root.noticeError = true } } @@ -166,7 +263,7 @@ Panel { try { var result = JSON.parse(text) if (result.quotas) root.quotaData = result.quotas - else root.receive(result) + root.receive(result) } catch (e) { root.notice = "Unable to read account limits."; root.noticeError = true } } } @@ -203,8 +300,18 @@ Panel { } } } + Timer { + interval: 100 + running: root.opened && root.snapshot.running && root.pageRefreshPending && !root.busy + repeat: false + onTriggered: { + root.pageRefreshPending = false + if (root.page === 2) root.perform(["preferences"]) + else if (root.page === 1) root.perform(["custom-list"]) + } + } Timer { interval: root.opened ? 5000 : 20000; running: true; repeat: true; onTriggered: root.refresh() } - Timer { interval: 60000; running: root.opened && root.snapshot.running; repeat: true; onTriggered: root.refreshQuotas(false) } + Timer { interval: root.opened ? 60000 : 300000; running: (root.opened || root.quotaAlerts) && root.snapshot.running; repeat: true; onTriggered: root.refreshQuotas(false) } Timer { interval: 2000; running: root.signingIn; repeat: true; onTriggered: if (!authPoll.running && !root.busy) authPoll.running = true } Timer { interval: 10000; running: root.opened; repeat: true; onTriggered: root.now = Date.now() / 1000 } @@ -214,6 +321,7 @@ Panel { bar: root.bar text: "󰚩" active: root.snapshot.running + activeColor: Color.accent tooltipText: "OmaProxy · " + (root.snapshot.running ? "Account limits" : "Proxy stopped") onPressed: root.toggle() Rectangle { @@ -525,22 +633,53 @@ Panel { } } PanelSeparator { foreground: root.foreground } - ActionButton { text: root.addingKey ? "Hide API provider form" : "+ API-key provider"; onClicked: root.addingKey = !root.addingKey } + Label { text: "API-key providers"; font.bold: true } + ActionButton { text: "Refresh providers"; enabled: root.snapshot.running && !root.busy; onClicked: root.perform(["custom-list"]) } + Repeater { + model: root.customProviders + Column { + required property var modelData + width: body.width + spacing: Style.space(6) + Label { width: parent.width; text: modelData.name + " · " + modelData.credential_count + " credential(s)"; wrapMode: Text.WordWrap } + Repeater { + model: modelData.credentials || [] + Hint { required property var modelData; text: "Credential " + modelData.index + " · weight " + (modelData.weight === null || modelData.weight === undefined ? "1 (default)" : modelData.weight) } + } + Row { + spacing: Style.space(6) + ActionButton { text: "Edit"; enabled: !root.busy; onClicked: root.editProvider(modelData) } + ActionButton { text: "Test models"; enabled: root.snapshot.running && !root.busy; onClicked: root.perform(["custom-test", modelData.name]) } + ActionButton { + text: root.removingProvider === modelData.name ? "Confirm removal" : "Remove" + enabled: root.snapshot.running && !root.busy + onClicked: { + if (root.removingProvider === modelData.name) { root.perform(["custom-remove", modelData.name]); root.removingProvider = "" } + else root.removingProvider = modelData.name + } + } + } + } + } + Hint { text: "Test models performs model discovery only. It does not send an inference request." } + ActionButton { text: root.addingKey ? "Hide API provider form" : "+ API-key provider"; onClicked: { if (root.addingKey) root.addingKey = false; else root.newProvider() } } Column { visible: root.addingKey width: parent.width spacing: Style.space(8) - Hint { text: "Add an OpenAI-compatible endpoint. Quota availability depends on the provider." } - Field { id: providerName; placeholderText: "Name, e.g. zai" } + Hint { text: root.editingProvider ? "Edit provider. A blank API key preserves its existing credentials." : "Add an OpenAI-compatible endpoint. Quotas may be unavailable." } + Field { id: providerName; placeholderText: "Name, e.g. zai"; enabled: root.editingProvider === "" } Field { id: providerUrl; placeholderText: "Base URL, e.g. https://provider.example/v1" } Field { id: providerKey; placeholderText: "API key"; password: true } - Field { id: providerModels; placeholderText: "Model IDs, separated by commas" } + Field { id: providerModels; placeholderText: "Model IDs, or JSON with name and alias" } + Hint { text: 'Aliases: [{"name":"upstream-model","alias":"coding-model"}]' } + Field { id: providerCredential; placeholderText: "Credential index (0 is the first)"; visible: root.editingProvider !== "" } + Field { id: providerWeight; placeholderText: "Optional weight (0 excludes this credential)"; visible: root.providerWeightsSupported } ActionButton { text: "Save provider" enabled: root.snapshot.running && !root.busy onClicked: { - root.perform(["custom-add"], {name: providerName.text, url: providerUrl.text, key: providerKey.text, models: providerModels.text}) - providerKey.text = "" + root.saveProvider() } } } @@ -557,6 +696,12 @@ Panel { tooltipText: "Show model-specific and shorter limits for all accounts" onClicked: root.setDisplaySetting("showExtraLimits", !root.showExtraLimits) } + ActionButton { + text: "Quota alerts: " + (root.quotaAlerts ? "On" : "Off") + active: root.quotaAlerts + onClicked: root.setDisplaySetting("quotaAlerts", !root.quotaAlerts) + } + Hint { text: "Optional alerts check limits every five minutes while closed. Stale or unknown limits never trigger low-allowance alerts." } PanelSeparator { foreground: root.foreground } Label { text: "Proxy settings"; font.bold: true } ActionButton { @@ -584,8 +729,84 @@ Panel { Hint { text: "Balance requests across accounts, or use one account until its allowance is exhausted." } Row { spacing: Style.space(6) - ActionButton { text: "Balance accounts"; active: root.preferences.routing === "round-robin"; enabled: root.snapshot.running && !root.busy; onClicked: root.perform(["routing", "round-robin"]) } - ActionButton { text: "Fill first"; active: root.preferences.routing === "fill-first"; enabled: root.snapshot.running && !root.busy; onClicked: root.perform(["routing", "fill-first"]) } + Repeater { + model: root.routingSettings.strategies.length ? root.routingSettings.strategies : ["round-robin", "fill-first"] + ActionButton { + required property string modelData + text: modelData === "round-robin" ? "Balance" : modelData === "weighted-round-robin" ? "Weighted" : "Fill first" + active: root.preferences.routing === modelData + enabled: root.snapshot.running && !root.busy + onClicked: root.perform(["routing", modelData]) + } + } + } + ActionButton { text: root.showingRouting ? "Hide routing details" : "Routing details"; onClicked: root.showingRouting = !root.showingRouting } + Column { + visible: root.showingRouting + width: parent.width + spacing: Style.space(8) + Repeater { + model: [{field: "session-affinity", label: "Keep conversations on one account"}, + {field: "session-affinity-subagents", label: "Subagents inherit the conversation account"}, + {field: "disable-cooling", label: "Disable cooldowns"}, + {field: "save-cooldown-status", label: "Persist cooldown state"}] + ActionButton { + required property var modelData + width: parent.width + text: modelData.label + ": " + (root.routingSettings.values[modelData.field] ? "On" : "Off") + active: root.routingSettings.values[modelData.field] === true + enabled: root.snapshot.running && !root.busy && root.routingSettings.capabilities[modelData.field] === true + onClicked: { var change = {}; change[modelData.field] = !root.routingSettings.values[modelData.field]; root.perform(["routing-save"], change) } + } + } + Field { id: affinityTtl; text: root.routingSettings.values["session-affinity-ttl"] || "1h"; placeholderText: "Conversation affinity duration, e.g. 1h" } + ActionButton { text: "Save affinity duration"; enabled: root.snapshot.running && !root.busy && root.routingSettings.capabilities["session-affinity-ttl"] === true; onClicked: root.perform(["routing-save"], {"session-affinity-ttl": affinityTtl.text}) } + Hint { text: "Additional retry rounds / credentials per round / maximum cooldown wait (seconds). A credential cap of 0 means all eligible credentials." } + Row { + width: parent.width + spacing: Style.space(6) + Field { id: retryRounds; width: (parent.width - parent.spacing * 2) / 3; text: String(root.routingSettings.values["request-retry"] === undefined ? 3 : root.routingSettings.values["request-retry"]) } + Field { id: retryCredentials; width: (parent.width - parent.spacing * 2) / 3; text: String(root.routingSettings.values["max-retry-credentials"] === undefined ? 0 : root.routingSettings.values["max-retry-credentials"]) } + Field { id: retryWait; width: (parent.width - parent.spacing * 2) / 3; text: String(root.routingSettings.values["max-retry-interval"] === undefined ? 30 : root.routingSettings.values["max-retry-interval"]) } + } + ActionButton { text: "Save retry limits"; enabled: root.snapshot.running && !root.busy && root.routingSettings.capabilities["request-retry"] === true && root.routingSettings.capabilities["max-retry-credentials"] === true && root.routingSettings.capabilities["max-retry-interval"] === true; onClicked: root.saveRetryLimits() } + Repeater { model: root.routingSettings.limitations || []; Hint { required property string modelData; text: modelData } } + } + PanelSeparator { foreground: root.foreground } + ActionButton { text: root.showingDiagnostics ? "Hide diagnostics" : "Show diagnostics"; enabled: root.snapshot.running && !root.busy; onClicked: { root.showingDiagnostics = !root.showingDiagnostics; if (root.showingDiagnostics) root.perform(["diagnostics"]) } } + Column { + visible: root.showingDiagnostics + width: parent.width + spacing: Style.space(8) + ActionButton { text: "Refresh counters"; enabled: !root.busy; onClicked: root.perform(["diagnostics"]) } + Hint { text: "Account counters describe backend attempts. Upstream-key counters exclude OAuth accounts; they are not client usage or billing totals." } + Hint { text: "Accounts: " + root.diagnosticSummary(root.diagnostics.accounts) } + Repeater { + model: (root.diagnostics.accounts || {}).records || [] + Label { required property var modelData; width: parent.width; text: modelData.provider + " · " + modelData.label + " · " + modelData.success + " succeeded / " + modelData.failed + " failed"; wrapMode: Text.WrapAnywhere; font.pixelSize: Style.font.caption } + } + Repeater { + model: (root.diagnostics.usage || {}).records || [] + Label { required property var modelData; width: parent.width; text: modelData.provider + " · " + modelData.label + " · " + modelData.success + " succeeded / " + modelData.failed + " failed"; wrapMode: Text.WrapAnywhere; font.pixelSize: Style.font.caption } + } + Hint { text: "Upstream keys: " + root.diagnosticSummary(root.diagnostics.usage) } + Hint { text: ((root.diagnostics.accounts || {}).error || (root.diagnostics.usage || {}).error || "") } + Hint { text: "Capture pending activity removes up to 50 usage events from the backend queue. Other collectors will not receive them. No prompts are retained." } + ActionButton { text: "Capture pending activity"; enabled: !root.busy; onClicked: root.perform(["capture-activity"]) } + Hint { text: (root.diagnostics.queue || {}).error || "" } + Hint { text: "Activity: " + root.diagnosticSummary(root.diagnostics.queue) } + Repeater { + model: (root.diagnostics.queue || {}).events || [] + Column { + required property var modelData + width: parent.width + spacing: Style.space(4) + Label { width: parent.width; text: modelData.client_name || modelData.client_label || "Client unavailable"; font.pixelSize: Style.font.caption } + Label { width: parent.width; text: (modelData.request_label || "Request ID unavailable") + " · " + (modelData.account_label || "Account unavailable") + " · " + (modelData.outcome || "Outcome unavailable") + (typeof modelData.latency_ms === "number" ? " · " + modelData.latency_ms + " ms" : ""); wrapMode: Text.WrapAnywhere; font.pixelSize: Style.font.caption } + Hint { width: parent.width; text: root.diagnosticEventDetails(modelData); visible: text !== "" } + } + } + Repeater { model: root.diagnostics.limitations || []; Hint { required property string modelData; text: modelData } } } PanelSeparator { foreground: root.foreground } Label { text: "Connect your coding tools"; font.bold: true } @@ -595,6 +816,39 @@ Panel { ActionButton { text: "Copy endpoint"; enabled: !clipboard.running; onClicked: root.copyValue("endpoint") } ActionButton { text: "Copy API key"; enabled: !clipboard.running; onClicked: root.copyValue("api-key") } } + ActionButton { + text: root.showingClientKeys ? "Hide client keys" : "Named client keys" + enabled: root.snapshot.running && !root.busy + onClicked: { root.showingClientKeys = !root.showingClientKeys; if (root.showingClientKeys) root.perform(["client-keys"]) } + } + Column { + visible: root.showingClientKeys + width: parent.width + spacing: Style.space(8) + Hint { text: "Create a separate key for each client, then configure that client with the endpoint and copied key. Existing clients keep working with the primary key." } + Field { id: clientName; placeholderText: "Client name, e.g. t3-code or codex-cli" } + ActionButton { text: "Create client key"; enabled: !root.busy && clientName.text.trim() !== ""; onClicked: root.perform(["client-create", clientName.text.trim()]) } + ActionButton { text: "Refresh client keys"; enabled: !root.busy; onClicked: root.perform(["client-keys"]) } + Repeater { + model: root.clientKeys + Column { + required property var modelData + width: body.width + spacing: Style.space(6) + Label { text: modelData.name + " · " + (modelData.active ? "Active" : "Creation unconfirmed or key removed"); width: parent.width; wrapMode: Text.WordWrap } + Row { + spacing: Style.space(6) + ActionButton { text: "Copy client key"; enabled: modelData.active && !clipboard.running && !root.busy; onClicked: root.copyClientKey(modelData.name) } + ActionButton { + text: root.revokingClient === modelData.name ? "Confirm revocation" : "Revoke" + enabled: !root.busy + onClicked: { if (root.revokingClient === modelData.name) { root.perform(["client-revoke", modelData.name]); root.revokingClient = "" } else root.revokingClient = modelData.name } + } + } + } + } + Hint { text: "Revoking a key disconnects clients using it. Keys are copied only to the clipboard; prompts and raw keys are absent from diagnostics." } + } ActionButton { text: (root.showingModels ? "Hide" : "Show") + " models (" + (root.snapshot.models || []).length + ")" onClicked: root.showingModels = !root.showingModels diff --git a/README.md b/README.md index 390e070..fda735d 100644 --- a/README.md +++ b/README.md @@ -29,6 +29,10 @@ A native Quickshell plugin for quotas, accounts, and your local AI proxy. - **Ready for screenshots.** Emails are softly blurred by default. Click to reveal, click again to hide; closing the popup conceals them automatically. Inline logs redact email addresses. - **Honest quota states.** Unknown is not zero. A failed refresh preserves the last reading with a stale-data warning. - **Independent service.** The proxy keeps running when the desktop shell reloads. +- **Routing controls.** Adjust supported strategies, session affinity, credential weights, and retry limits. See [routing and API providers](docs/routing.md). +- **Private diagnostics.** Inspect available account counters and upstream-key aggregates, or explicitly capture the consuming activity queue. See [diagnostics and their limits](docs/diagnostics.md). +- **Named client keys.** Create, copy, and revoke separate downstream keys while preserving the primary key. See [client keys](docs/client-keys.md). +- **Optional quota alerts.** Enable low-quota, observed reset, and explicit authentication alerts. See [desktop alerts](docs/quota-alerts.md).
See account management @@ -143,6 +147,8 @@ Integration tests use a separate proxy on an ephemeral loopback port and a mock See [Codex and T3 compatibility checks](docs/client-compatibility.md) for Responses streaming, tool calls, WebSockets, interruption and optional real Codex CLI/app-server lanes. These fixtures verify local protocol behavior; actual T3 UI and authenticated provider behavior require separate acceptance checks. +Use the [isolated native preview](docs/native-preview.md) to exercise the panel with fake receipts and installed Omarchy components without changing your live plugin or service. + [Contributing](CONTRIBUTING.md) · [Architecture](docs/architecture.md) · [Report a bug](https://github.com/soojy/omaproxy/issues/new?template=bug_report.md) ## Credits diff --git a/docs/client-keys.md b/docs/client-keys.md new file mode 100644 index 0000000..100a782 --- /dev/null +++ b/docs/client-keys.md @@ -0,0 +1,52 @@ +# Named client access keys + +Use a separate downstream access key for each client, for example `codex-cli`, `t3-code`, `opencode`, `agy` or `kiro-cli`. Creating a named key enables it on the proxy. Copy it to the intended client's credential configuration. Creating a key does not change that client's configuration automatically. + +Client names contain 1-60 ASCII letters, digits, dots, underscores or hyphens and start with a letter or digit. Names identify the keys the user created here. Existing backend keys remain unmanaged; the helper does not assign names to the primary key or other existing credentials. + +## Storage and display + +The private `client-keys.json` registry contains only a name, a keyed HMAC label and a creation state for each managed key. It never stores raw access keys. Writes use an atomic replacement with mode `0600`; newly created registry directories use mode `0700`. Invalid, oversized or symlinked registry files fail without replacement. + +`key_label` is `client-` followed by the first 16 hex characters of HMAC-SHA256 over `client\0` plus the access-key value. The private management key is the HMAC salt. This matches diagnostics' client labels when diagnostics uses the same salt. The salt must be stable and nonempty. Changing the management key changes these labels; old registry entries cannot then be matched automatically. + +Lists return only `{name, key_label, active}` rows. `active` describes whether the exact managed key appears in the current backend access-key list. An unavailable or invalid backend list raises a fixed error; it does not turn missing evidence into inactive rows. Both registry and backend lists are limited to 200 entries, and the registry is limited to 64 KiB. An oversized backend list blocks mutations rather than truncating the population. + +Copy fetches the access-key list from the backend, matches the HMAC label, and sends the selected raw key only to `wl-copy` standard input. The token is absent from command arguments and returned JSON. Clipboard output and error streams are discarded. Errors use fixed messages without exception bodies. The clipboard contains the copied credential until the user replaces or clears it. + +## Backend contract and recovery + +The verified v7 and v8 sources retain the downstream `/v0/management/api-keys` contract: + +| Operation | Request | +| --- | --- | +| Read | `GET /v0/management/api-keys`, response `{"api-keys": [...]}` | +| Create | `PATCH /v0/management/api-keys` with `{"old": generated_value, "new": generated_value}` | +| Revoke | `DELETE /v0/management/api-keys?value=encoded_value` | + +For `PATCH`, matching `old` replaces that exact entry; an absent `old` appends `new`. Setting both to the same generated key makes a repeated append idempotent. The helper never submits a full replacement array or deletes by index. + +Before creation, the helper writes a pending HMAC record. It marks the record active only after backend readback finds the key. A failed response or readback retains that record. Repeating creation with the same name reads the existing key and does not generate another credential. If the named key remains absent, creation fails until the user refreshes or revokes the inactive record. Revocation of an inactive record removes only its local name. + +The backend's delete-by-value operation compares trimmed values. Revocation refuses a primary-key match or any distinct key with the same trimmed value. Callers must supply the real `settings.api_key` as `primary_key` to revoke; omission fails before contacting the backend. Readback must confirm absence before removing the name. A failed revocation retains the record for retry. Other backend keys remain untouched. + +The caller must hold its management mutation lock across creation and revocation, including registry writes and readback. This protects local named operations; it does not lock changes made independently in another management client. The helper does not read private settings itself. + +## Client attribution + +A separate key supports attribution only when a request receipt actually includes the downstream client access-key value or an explicitly documented downstream client-key source field. Match that value with the same HMAC salt, then resolve its named record. + +Upstream provider API-key counters, OAuth account counters, model names and routing choices do not identify a downstream client. A named key list proves access configuration, not that a particular request came from the named client. The current upstream aggregate diagnostics do not supply that request attribution. + +## Helper API + +```python +list_keys(api, path, salt, primary_key=None) +create_key(api, path, salt, name, primary_key=None) +revoke_key(api, path, salt, name, primary_key=None) +copy_key(api, path, salt, name, primary_key=None, runner=subprocess.run) +``` + +`path` is the complete registry filename. `api` accepts absolute management routes with `method="GET"`, `body=None` and optional `timeout=4`; it raises on request failures. Creation returns `client_keys` and the sanitized selected `client_key`. Revocation returns `client_keys`, `revoked` and `name`. Copy returns `copied`, `name` and `key_label`. No result contains the raw key. + +Run `python -m unittest discover -s tests -p test_client_keys.py -v`. Set `OMAPROXY_TEST_BINARY` to a vetted backend executable to enable the isolated integration test. It starts a temporary loopback backend, creates and revokes one key, and verifies primary-key, upstream-key and YAML-comment preservation. It neither reads nor modifies live settings or the live service. diff --git a/docs/diagnostics.md b/docs/diagnostics.md new file mode 100644 index 0000000..f603241 --- /dev/null +++ b/docs/diagnostics.md @@ -0,0 +1,57 @@ +# Diagnostics and telemetry scope + +`diagnostics.snapshot(api, accounts=None, salt=None, consume_queue=False)` returns a bounded, redacted snapshot. Its adapter accepts full management paths and a `timeout` keyword, performs a GET, and raises an HTTP error for unsuccessful responses. Each call uses a two-second timeout. Up to four calls probe usage and accounts; an explicit queue capture can add two calls. The bridge rejects JSON response bodies above 2 MiB before decoding them. + +The return value contains `usage`, `accounts`, `queue`, `client_attribution`, and `limitations`. Usage and accounts contain `availability`, `source`, `error`, `records`, `retained`, `omitted`, and `invalid`. Supported records contain anonymous `label`, `provider`, `success`, `failed`, and `recent_requests`. No overall activity total is inferred from an incomplete population. Counts are cumulative backend attempt counters; they do not count unique client requests or prove billing charges. Retention is limited to 128 records and 20 recent buckets per record. Unknown values are not replaced with zero. + +Pass the already fetched raw auth-file list through `accounts` to avoid fetching it again. Pass a private, stable installation salt (for example, the existing management credential, kept in Python) through `salt` to keep labels stable across bridge invocations. The salt never enters the result. Without it, labels remain stable only within the Python process. Resetting the salt changes every anonymous label. The helper writes no files and retains no request history. + +## Verified routes and schemas + +The implementation was checked against these exact upstream tags: + +- [v7.2.154](https://github.com/router-for-me/CLIProxyAPI/tree/v7.2.154), commit `ba7e55836dee959e93ec6d41395865d9ec535086`. +- [v8.0.13](https://github.com/router-for-me/CLIProxyAPI/tree/v8.0.13), commit `d7914afdedca7af95ee974a42453dc49fc1388ce`. + +| Data | v8 route | Legacy route | Meaning | +| --- | --- | --- | --- | +| Upstream API-key counters | `/v8/management/observability/usage/api-keys` | `/v0/management/api-key-usage` | Upstream API-key accounts grouped by provider and `base_url\|api_key`; excludes OAuth credentials | +| Account counters | `/v8/management/credentials` | `/v0/management/auth-files` | `files` array of runtime account records with `auth_index`, provider, success, failed and recent requests | +| Pending receipts, explicit capture only | `/v8/management/observability/usage/queue?count=50` | `/v0/management/usage-queue?count=50` | Removes up to 50 records from the shared usage queue | + +The API-key usage endpoint does **not** count client access keys. A zero-length result does not prove that an OAuth account received no traffic. Account counters are useful for OAuth coverage but do not establish which account handled a particular client request. The legacy `/v0/management/usage` route is not used: it is absent in both inspected tags, and an installed backend returning 404 is not evidence that enabling usage statistics restores that route. + +The usage response is a provider-keyed map whose second-level keys contain upstream secrets. The helper replaces every composite key with an anonymous label. Account responses contain private filenames and emails; those fields are dropped. Known provider IDs use a fixed allowlist. Custom provider names are anonymous. Recent bucket labels accept only `HH:MM-HH:MM`, use backend local time, and contain neither dates nor a timezone. + +Primary source: + +- [v8 API-key usage handler](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/api/handlers/management/api_key_usage.go), also present in v7.2.154. +- [v8 credential response builder](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/api/handlers/management/auth_files.go), also contains counters in v7.2.154. +- [v8 management routes](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/api/server_management_v8.go) and [v7 legacy routes](https://github.com/router-for-me/CLIProxyAPI/blob/v7.2.154/internal/api/server_management.go). +- [v8 queue handler](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/api/handlers/management/usage.go) calls `PopOldest`; this is also true in v7.2.154. +- [v8 queue receipt schema](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/internal/redisqueue/plugin.go). +- [Recent bucket schema](https://github.com/router-for-me/CLIProxyAPI/blob/v8.0.13/sdk/cliproxy/auth/types.go): 20 buckets of 10 minutes each. + +## Explicit queue capture + +Automatic snapshots never read the queue. Although its HTTP method is GET, the backend removes records as it returns them. A separate action may call `snapshot(..., consume_queue=True)` only after explaining that other collectors lose access to the consumed events. This is a capture of currently pending receipts, not a request log, a queue peek, a continuous monitor, or a complete history. An empty queue may mean no recent records, disabled statistics, disabled queue publishing, expired retention, or that another consumer already removed them. + +The capture returns only the current snapshot, with at most 50 events. `sanitize_events(items, salt=...)` applies the same boundary to records supplied by another authorized consumer without making requests. Client labels come only from a receipt's `api_key`; account labels come only from `auth_index`. No attribution is inferred from account eligibility, source filenames, configured provider keys, or user agents. Request and execution labels preserve only grouping relationships. Model names become anonymous labels because arbitrary model strings can contain private text. + +For a returned array, `retained` counts supported displayed receipts, `invalid` counts unsupported records among the newest 50 inspected records, and `omitted` counts older records beyond that inspection limit. These populations are separate: `retained + invalid + omitted` equals the number of records returned by the backend. A capture with some supported receipts remains `available` and reports its invalid count. A nonempty inspected population with no supported receipts reports `unknown`, while an empty array remains `available` with zero receipts. + +`capture_requested` records the explicit capture action. `consumed` means the queue request succeeded, even if its response could not be parsed or all receipts were invalid; it does not mean receipts were retained or identified. Failed requests report `consumed: false`. `client_attribution` is `receipt_fields_only` only when at least one retained receipt supplies a valid `api_key` that becomes a `client_label`. Supported account or request fields alone leave it `unavailable`, and one identified receipt does not identify every other receipt. + +Outcome, HTTP status, latency, time to first token (TTFT), and allowlisted token counts appear only when the receipt supplies correctly typed fields. A backend zero remains a backend zero; the helper does not prove whether it was measured or a backend default. Missing fields are omitted, not estimated. The inspected receipt schema does not provide a retry count, so the helper does not invent one. Failure bodies, headers, response bodies, prompts, messages, tool data, email addresses, raw access keys, auth filenames, network addresses and arbitrary error text never enter display output. + +## Availability and verification + +`available` means the response matched the inspected schema, even if the supported population is empty. `unsupported` means both route variants returned 404, 405, or 501. `unavailable` means another request failure, including denied access or a timeout; it does not trigger another route probe. `unknown` means the payload schema could not be recognized. Default queue snapshots report `read_only_unavailable` because reading that endpoint has a side effect. Exception messages and backend error bodies are replaced by fixed messages. + +The tests use fake responses, HTTP failures and hostile secret-bearing fields. They check endpoint fallback, missing capabilities, OAuth/account separation, malformed counts, receipt attribution, output bounds, stable labels, exception redaction, and explicit queue capture. Capture regressions distinguish retained, invalid and omitted records; verify that an all-invalid response reports an unknown schema; and keep capture success separate from client attribution. Run: + +```sh +python3 -m unittest discover -s tests -p test_diagnostics.py -v +``` + +These tests verify the local parsing and privacy boundary. They do not prove telemetry publishing or queue retention on an installed backend, and no live inference is needed to run them. diff --git a/docs/native-preview.md b/docs/native-preview.md new file mode 100644 index 0000000..c71dc29 --- /dev/null +++ b/docs/native-preview.md @@ -0,0 +1,102 @@ +# Native preview + +Run the checkout QML on an Omarchy desktop with its installed Quickshell and +shared shell components: + +```sh +python3 scripts/preview-plugin.py --page settings +# Preview another checkout without copying infrastructure into it: +python3 scripts/preview-plugin.py --repo /path/to/checkout --page settings +``` + +The launcher creates a private temporary configuration, copies `BarWidget.qml`, +`LimitModel.js` and assets, and links the installed `Commons` and `Ui` modules. +It replaces the Python bridge with `tests/fixtures/preview_bridge.py`. All +accounts use `example.invalid` addresses. The fixture has no network, service, +clipboard, provider, installation or real configuration operations. + +The separate preview bar and popup briefly take native keyboard focus. They use +the actual Omarchy `Panel`, `KeyboardPanel`, `BarIconButton` and control types. +The existing shell stays running. Press Ctrl+C in the launching terminal to +stop the preview. Use `--duration 30` to stop it automatically. Temporary files +are removed on exit unless `--keep` is supplied. `--keep` retains the copied +source, fixture state, command trace, log and smoke capture in a private `/tmp` +directory; remove that directory after inspecting it. + +The launcher prints its configuration path and a scoped IPC command. Always +pass that exact path when addressing the preview. For example: + +```sh +quickshell ipc -p /tmp/omaproxy-preview-EXAMPLE call soojy.omaproxy showPage accounts +quickshell ipc -p /tmp/omaproxy-preview-EXAMPLE call omaproxy-preview controls +quickshell ipc -p /tmp/omaproxy-preview-EXAMPLE call omaproxy-preview state +quickshell ipc -p /tmp/omaproxy-preview-EXAMPLE call omaproxy-preview quit +``` + +The plugin IPC target keeps its normal name but configuration selection isolates +it from the installed plugin. The extra `omaproxy-preview` target belongs only +to the temporary host. Its `activate` function invokes one enabled, visible +native button's click handler by exact label. Its `capture` function captures +the rendered popup card through Qt's `grabToImage`; it excludes unrelated +windows and desktop content. + +## Repeatable verification + +```sh +python3 scripts/preview-plugin.py --smoke --keep +``` + +The smoke lane waits for fixture status and switches through native tab controls. +It detects the feature set in the checkout and verifies the corresponding +handlers and state transitions: + +- Cold Settings startup with its initial status response deliberately delayed. + The lane observes the initial stopped snapshot and verifies that the queued + `preferences` request loads routing values before any tab navigation. It waits + for queued page refreshes to finish before activating subsequent controls. +- Backend update check, reviewed fixture install and restore. +- Weighted routing, conversation affinity, subagent affinity, cooldown toggles, + duration and retry edits, rejecting blank or fractional retry inputs before + invoking the bridge, quota alert opt-in and opt-out, and alert-delivery errors + from an opted-in native quota refresh. The fixture sends no notifications. +- Read-only diagnostics refresh and explicit fixture activity capture, displayed + retained/unrecognized/omitted counts, named client labels, timestamp, HTTP + status, model pseudonym, latency, first-token time and token counters. +- Named client-key creation, refresh and copy, staged revocation reset on page + changes, and confirmed revocation. No raw key is generated, displayed or copied + by the fixture. +- Provider discovery, editing JSON model aliases, preserving credential counts, + credential weights, staged removal, confirmation reset on page changes, + confirmed removal and creation with dummy credentials. URL-only edits omit + unchanged models from the stdin payload and preserve aliases. The fixture + uses the `provider_weights_supported` capability and public credential rows. + Confirmed saves close the form; reopening starts with the refreshed values. +- Remote connection save with dummy keys, saved client-key removal and local + connection selection. + +It closes and reopens Accounts, checks that the email reveal state is empty, +that password fields are cleared and that the active Accounts tab refreshes, +captures the rendered cards, waits for complete PNG files, and checks the +fixture command trace. It exits nonzero if a control, state transition or +capture is missing. Field values use percent-encoded IPC transport so brackets +in JSON aliases arrive intact. The native form still parses those values and +submits its own normal stdin payload to the fixture. + +Inspect the retained PNG files and `quickshell.log` before treating the visual +check as complete. All contracts return synthetic values. Quota alert settings +use the temporary bar host; the fixture never sends desktop notifications. + +This lane verifies QML loading, native rendering, binding and action wiring, +and concealment after reopening. It does not verify live upstream responses, +downloads, service restarts, billing totals, provider delivery, clipboard +contents or pointer hit testing. Prefer AT-SPI inspection when available; on +Quickshell 0.3.1 here the Qt application exposes no top-level AT-SPI windows. +The scoped native control bridge keeps the lane executable despite that gap. + +Recorded validation on the development desktop used Quickshell 0.3.1 and the +installed Omarchy components. The updater, controls and remote smoke lanes +passed; their cards were visually inspected. The final controls lane also +covered named client keys, public provider weights, diagnostic population +counts, activity metadata, cold Settings startup and alert-delivery errors. The +logs contained a host portal registration +warning and no QML errors. The fixture never executed the real backend bridge. diff --git a/docs/quota-alerts.md b/docs/quota-alerts.md new file mode 100644 index 0000000..f5d3705 --- /dev/null +++ b/docs/quota-alerts.md @@ -0,0 +1,43 @@ +# Quota desktop alerts + +Quota alerts are optional and stay off unless the user enables them in the +OmaProxy panel. The bridge should call `quota_alerts.process(snapshot, +config_path)` only after it has written a fresh quota snapshot and only while +the preference is enabled. The helper does not fetch provider data or change +the proxy configuration. + +The helper uses `notify-send` for local desktop notifications. It reports low +quota at 10% remaining or less, and reports a quota refresh only when two fresh +backend readings show a changed reset time and an increase in available quota. +A timer passing a reset time is not enough. Authentication alerts use only an +explicit account-health `status` of `auth_error`, `authentication_error`, +`expired`, or `unauthorized`; generic `error`/`failed` status values and a quota +lookup's `error` field never trigger an authentication alert. + +Alerts are ignored when the snapshot or account reading is more than ten +minutes old, when quota data is stale or has an error, when the account is +disabled, or when the remaining percentage is unknown or invalid. Unrecognized +window labels are not shown. Notification text uses a generic provider label +and a standard window label; it never includes account names, email addresses, +auth filenames, auth indexes, or provider error text. + +Sent and pending alert keys use unsalted hashed account and window identifiers. +These are pseudonyms; predictable identifiers can still be guessed. The +bounded state file and lock live in the private OmaProxy config directory: + +```text +${XDG_CONFIG_HOME:-~/.config}/omaproxy/quota-alerts.json +${XDG_CONFIG_HOME:-~/.config}/omaproxy/quota-alerts.lock +``` + +The state file is written atomically with mode `0600` and retains at most 200 +entries. Failed notification commands remain pending and can retry on a later +fresh snapshot. If `notify-send` is missing, the first attempted alert returns +an explicit unsupported result, keeps the alert pending, and suppresses repeat +unsupported messages. The runner has a five-second timeout, does not use a +shell, and receives only desktop-session environment variables needed to +contact the notification service. + +The processing function returns a sanitized result such as +`{"alert_count": 1}`. It may add a generic `error` when private state cannot be +used, notifications fail, or the desktop notification utility is unavailable. diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 0000000..b753509 --- /dev/null +++ b/docs/routing.md @@ -0,0 +1,118 @@ +# Routing and custom providers + +OmaProxy reads backend capabilities before offering routing controls. Discovery +uses management GET requests and returns only an allowlist of display values. +It does not save config, reset cooldowns, or call a model. + +## Backend contracts + +The implementation was checked against CLIProxyAPI tags +[v7.2.154](https://github.com/router-for-me/CLIProxyAPI/tree/v7.2.154) and +[v8.0.13](https://github.com/router-for-me/CLIProxyAPI/tree/v8.0.13). +Both accept `round-robin`, `weighted-round-robin`, and `fill-first` through +`/v0/management/routing/strategy`. Weighted routing is offered for a known +compatible backend version, or when the backend reports that it is already +using that strategy. Unknown versions retain round-robin and fill-first. + +| Control | v7.2.154 | v8.0.13 | +| --- | --- | --- | +| Strategy | Scalar PATCH | Routing subtree PATCH | +| Retry rounds, credential cap, maximum wait | Scalar PATCH | Routing subtree PATCH | +| Session affinity, TTL, subagent inheritance | Read only | Routing subtree PATCH | +| Disable cooling, persist cooldown state | Read only | Routing subtree PATCH | +| Custom provider create | Locked list GET and PUT | Locked list GET and PUT via v0 compatibility API | +| Custom provider edit | Exact-name PATCH | Exact-name PATCH via v0 compatibility API | +| Custom provider remove | Exact-name DELETE | Exact-name DELETE via v0 compatibility API | + +The v7 config contains affinity fields, but its management API has no narrow +mutation route for them. OmaProxy does not replace full config JSON or rewrite +YAML to work around that gap. On v8, it merges only requested fields into +`/v8/management/config/routing`; backend tests verify that comments and unrelated +settings survive. A successful v8 mutation can migrate a legacy config document +to the backend's v8 layout. GET discovery does not migrate it. + +The verified source contracts are in `internal/api/server_management.go`, +`internal/api/handlers/management/config_basic.go`, and +`internal/api/handlers/management/config_lists.go`. V8 adds +`internal/api/server_management_v8.go` and +`internal/api/handlers/management/config_v8.go`. + +## What the choices mean + +- Round-robin rotates eligible credentials for the requested model. +- Fill-first favors the first eligible credential until it becomes unavailable. +- Weighted round-robin distributes eligible credentials in proportion to their + configured weights. An omitted credential weight defaults to 1; 0 excludes + that credential. The maximum weight is 1,000,000. Higher priority still takes + precedence over weight. Weight is not a model substitution setting. +- Session affinity binds an identified session to its credential and fails over + when that credential becomes unavailable. Its default TTL is one hour. + Subagent inheritance keeps a child session with its parent's credential; + disabling inheritance lets the fallback selector distribute child sessions. + +The UI exposes bounded edits: 0–10 additional retry rounds, 0–100 retry +credentials, 0–300 seconds of maximum cooldown wait, and a TTL between one second +and 24 hours. A credential cap of 0 uses the backend's unlimited default; the +round and wait limits are separate. These limits constrain new edits; discovery +still shows existing values outside them. Disabling cooling changes failure +handling, so OmaProxy only does it after an explicit settings change. + +Routing helpers never edit model aliases, quota-exceeded model switches, +credential priority, or requested model IDs. Existing backend rules for those +settings still apply. The caller holds the shared management mutation lock. +V8 applies all requested routing fields in one PATCH. V7 scalar writes are +sequential; a failure identifies fields already applied and asks for a refresh. +Every successful operation reads back the changed values before reporting +success. + +## Custom provider operations + +Provider names are exact targets and cannot be renamed by the edit action. +Names created or managed here use letters, numbers, underscores, or hyphens, +up to 60 characters. Duplicate names block edits and removals. + +Display results contain only name, validated base URL, model names and aliases, +disabled state, credential count, credential indexes, whether a key exists, and +weights. They exclude API keys, headers, proxy settings, and raw config. +Credential form values travel through stdin into Python and management request +bodies; they do not appear in command arguments or display JSON. + +An edit sends only supplied fields. A blank key preserves all existing secrets. +Replacing a key or changing its weight preserves other credential fields; a +provider with multiple credentials requires an explicit credential index. +Setting weight to JSON `null` restores the backend default. Editing a model +that retains its name and alias preserves its existing capability metadata. +Creation requires at least one explicit model ID, and remote providers require +a key. A localhost provider may use no key. Removal deletes only the exact name. + +The v0 API has no append endpoint. Creation therefore reads the current provider +list and writes the preserved list plus the new definition while holding the +same local lock as other config changes. This lock coordinates OmaProxy commands; +it cannot prevent an independent management client from editing between those +requests. Edits and deletions use target-specific endpoints and do not replace +unrelated provider definitions. + +## Explicit provider tests + +A provider test performs exactly one `GET /models` using the selected +credential. It does not infer a model, change configured aliases, or make a +completion request. A successful model catalog proves discovery at that endpoint; +it does not prove inference, quota, or compatibility with another protocol. +Custom header configurations are unsupported by this test and return a clear +error before sending a request. + +Base URLs reject embedded credentials, query parameters, fragments, whitespace, +control characters, and remote HTTP. HTTP is accepted only for `localhost`, +`127.0.0.1`, or `::1`. The caller's request function must disable redirects and +HTTP proxy inheritance so credentials cannot follow a redirect. Provider error +responses are reduced to an HTTP status or a generic failure message. + +## Verification + +`python -m unittest discover -s tests -p 'test_routing.py' -v` and the equivalent +command for `test_providers.py` run mock contract tests. Set +`OMAPROXY_TEST_BINARY=/path/to/cli-proxy-api` to also run isolated backend tests. +They create a temporary config and listener, start a separate backend process, +and terminate it afterwards. They verify narrow writes, retained provider +secrets, exact removal, and comment preservation. They do not change the live +service, live accounts, or production config, and make no inference requests. diff --git a/scripts/client_keys.py b/scripts/client_keys.py new file mode 100644 index 0000000..79299f7 --- /dev/null +++ b/scripts/client_keys.py @@ -0,0 +1,217 @@ +"""Named downstream access keys. The caller owns the management mutation lock. + +No raw key crosses this module's return boundary or enters the local registry. +""" +import hashlib +import hmac +import json +import os +from pathlib import Path +import re +import secrets +import subprocess +import tempfile +from urllib.parse import urlencode + +ROUTE = "/v0/management/api-keys" +MAX_KEYS = 200 +MAX_REGISTRY_BYTES = 65536 +_NAME = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,59}\Z") +_LABEL = re.compile(r"client-[a-f0-9]{16}\Z") + + +def _salt(salt): + if isinstance(salt, str): + salt = salt.encode() + if not isinstance(salt, bytes) or not salt: + raise ValueError("A private management salt is required.") + return salt + + +def _label(key, salt): + return "client-" + hmac.new(_salt(salt), ("client\0" + key).encode(), hashlib.sha256).hexdigest()[:16] + + +def _name(name): + if not isinstance(name, str) or not _NAME.fullmatch(name): + raise ValueError("Client name must use 1-60 letters, digits, dots, underscores or hyphens.") + return name + + +def _read(path): + path = Path(path) + try: + if path.is_symlink(): + raise ValueError() + if not path.exists(): + return [] + if path.stat().st_size > MAX_REGISTRY_BYTES: + raise ValueError() + document = json.loads(path.read_text()) + records = document["client_keys"] + if document.get("version") != 1 or not isinstance(records, list) or len(records) > MAX_KEYS: + raise ValueError() + names, labels = set(), set() + for record in records: + if not isinstance(record, dict) or set(record) != {"name", "key_label", "state"}: + raise ValueError() + _name(record["name"]) + if not isinstance(record["key_label"], str) or not _LABEL.fullmatch(record["key_label"]): + raise ValueError() + if record["state"] not in ("pending", "active") or record["name"] in names or record["key_label"] in labels: + raise ValueError() + names.add(record["name"]) + labels.add(record["key_label"]) + return records + except Exception: + raise ValueError("Client-key registry is unavailable or invalid; no changes were made.") from None + + +def _write(path, records): + path = Path(path) + temporary = None + try: + if path.is_symlink(): + raise ValueError() + path.parent.mkdir(mode=0o700, parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=".client-keys-", dir=path.parent) + with os.fdopen(descriptor, "w") as stream: + os.fchmod(stream.fileno(), 0o600) + json.dump({"version": 1, "client_keys": records}, stream) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + temporary = None + except Exception: + raise ValueError("Client-key registry could not be saved; refresh before retrying.") from None + finally: + if temporary: + try: + os.unlink(temporary) + except OSError: + pass + + +def _fetch(api): + try: + payload = api(ROUTE) + keys = payload["api-keys"] + # Do not silently omit records: mutations require a complete, valid list. + if "error" in payload or not isinstance(keys, list) or len(keys) > MAX_KEYS: + raise ValueError() + if any(not isinstance(key, str) or not key.strip() or len(key) > 16384 for key in keys): + raise ValueError() + return keys + except Exception: + raise ValueError("Client access-key list is unavailable or incomplete; no success was confirmed.") from None + + +def _matches(record, keys, salt): + return [key for key in keys if hmac.compare_digest(record["key_label"], _label(key, salt))] + + +def _rows(records, keys, salt): + return [{"name": record["name"], "key_label": record["key_label"], + "active": bool(_matches(record, keys, salt))} for record in records] + + +def list_keys(api, path, salt, primary_key=None): + """Show only explicitly named keys. Unmanaged backend keys remain anonymous.""" + _salt(salt) + records = _read(path) + return {"client_keys": _rows(records, _fetch(api), salt)} + + +def _find(records, name): + name = _name(name) + for record in records: + if record["name"] == name: + return record + raise ValueError("Named client key was not found.") + + +def _selected(records, keys, salt, name): + rows = _rows(records, keys, salt) + return {"client_keys": rows, "client_key": next(row for row in rows if row["name"] == name)} + + +def create_key(api, path, salt, name, primary_key=None): + """Idempotent by name, including a write whose response/readback was lost.""" + _salt(salt) + _name(name) + records, keys = _read(path), _fetch(api) + existing = next((record for record in records if record["name"] == name), None) + if existing: + if not _matches(existing, keys, salt): + raise ValueError("Named key is inactive or creation is unconfirmed; refresh or revoke its record before recreating.") + if existing["state"] == "pending": + existing["state"] = "active" + _write(path, records) + return _selected(records, keys, salt, name) + if len(records) >= MAX_KEYS or len(keys) >= MAX_KEYS: + raise ValueError("Client-key limit reached.") + key = "oma-" + secrets.token_urlsafe(32) + if key in keys or key == primary_key or any(record["key_label"] == _label(key, salt) for record in records): + raise ValueError("Could not generate a unique client key; retry.") + record = {"name": name, "key_label": _label(key, salt), "state": "pending"} + records.append(record) + # Preserve attribution even if the process exits after the server applies PATCH. + _write(path, records) + try: + # old==new updates an existing exact value or appends if absent. + api(ROUTE, "PATCH", {"old": key, "new": key}) + except Exception: + pass # The response can fail after mutation; the readback decides success. + keys = _fetch(api) + if not _matches(record, keys, salt): + raise ValueError("Client-key creation was not confirmed; its pending record was retained. Refresh before retrying.") + record["state"] = "active" + _write(path, records) + return _selected(records, keys, salt, name) + + +def revoke_key(api, path, salt, name, primary_key=None): + """Delete one exact named value. Never delete by index or replace an array.""" + _salt(salt) + if not isinstance(primary_key, str) or not primary_key.strip(): + raise ValueError("Primary-key protection is required before revoking client keys.") + records, keys = _read(path), _fetch(api) + record = _find(records, name) + matches = _matches(record, keys, salt) + if matches: + if len(set(matches)) != 1: + raise ValueError("Named client key is ambiguous; no changes were made.") + key = matches[0] + # DELETE's backend contract compares TrimSpace(value), not exact bytes. + if (primary_key is not None and key.strip() == str(primary_key).strip()) or any( + other != key and other.strip() == key.strip() for other in keys): + raise ValueError("This key is protected or ambiguous; no changes were made.") + try: + api(ROUTE + "?" + urlencode({"value": key}), "DELETE") + except Exception: + pass + keys = _fetch(api) + if _matches(record, keys, salt): + raise ValueError("Client-key revocation was not confirmed; its record was retained. Refresh before retrying.") + records.remove(record) + _write(path, records) + return {"client_keys": _rows(records, keys, salt), "revoked": True, "name": name} + + +def copy_key(api, path, salt, name, primary_key=None, runner=subprocess.run): + """The raw key goes only to wl-copy stdin, never argv, output or registry.""" + _salt(salt) + records, keys = _read(path), _fetch(api) + record = _find(records, name) + matches = _matches(record, keys, salt) + if len(set(matches)) != 1: + raise ValueError("Named client key is inactive or ambiguous.") + try: + result = runner(["wl-copy"], input=matches[0], text=True, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, timeout=4, check=False) + if result.returncode != 0: + raise ValueError() + except Exception: + raise ValueError("Client key could not be copied.") from None + return {"copied": True, "name": name, "key_label": record["key_label"]} diff --git a/scripts/diagnostics.py b/scripts/diagnostics.py new file mode 100644 index 0000000..e5e6aad --- /dev/null +++ b/scripts/diagnostics.py @@ -0,0 +1,243 @@ +"""Bounded, read-only CLIProxyAPI metadata. Raw responses never cross this boundary.""" + +import hashlib +import hmac +import os +import re +from datetime import datetime +from urllib.error import HTTPError + +MAX_RECORDS = 128 +MAX_BUCKETS = 20 +MAX_EVENTS = 50 +TIMEOUT = 2 +_SESSION_SALT = os.urandom(32) +_PROVIDERS = frozenset(("codex", "claude", "gemini", "qwen", "kimi", "antigravity", + "github-copilot", "openai", "openai-compatible", "unknown")) +_TOKENS = ("input_tokens", "output_tokens", "reasoning_tokens", "cached_tokens", + "cache_read_tokens", "cache_creation_tokens", "total_tokens") +_USAGE_PATHS = ("/v8/management/observability/usage/api-keys", "/v0/management/api-key-usage") +_ACCOUNT_PATHS = ("/v8/management/credentials", "/v0/management/auth-files") +_QUEUE_PATHS = ("/v8/management/observability/usage/queue?count=50", "/v0/management/usage-queue?count=50") +_QUEUE_NOTE = "Queue reads remove records from the backend. Automatic diagnostics do not consume the queue." +_LIMITATIONS = [ + "Upstream API-key counters exclude OAuth accounts and do not identify client access keys.", + "Account counters describe backend attempts, not unique user requests or billing totals.", + "Counters and recent buckets are backend memory snapshots; restarts can reset them.", + "Client identity, request routing, models, retries, latency, TTFT and tokens are unavailable from these aggregate counters.", + "Recent buckets use backend local time and contain no date or timezone.", +] + + +def _salt(value): + if isinstance(value, str) and value: + return value.encode() + if isinstance(value, bytes) and value: + return value + return _SESSION_SALT + + +def _label(kind, value, salt): + if not isinstance(value, str) or not value or len(value) > 16384: + return None + digest = hmac.new(salt, (kind + "\0" + value).encode(), hashlib.sha256).hexdigest()[:16] + return kind + "-" + digest + + +def _provider(value, salt): + return value if isinstance(value, str) and value in _PROVIDERS else _label("provider", value, salt) or "unknown" + + +def _number(value): + # JSON booleans, numeric strings, negatives and nonfinite values are not counts. + return value if type(value) is int and 0 <= value <= 2**63 - 1 else None + + +def _counter(record): + if not isinstance(record, dict): + return None + success, failed = _number(record.get("success")), _number(record.get("failed")) + if success is None or failed is None or not isinstance(record.get("recent_requests"), list): + return None + buckets = [] + for bucket in record["recent_requests"][-MAX_BUCKETS:]: + if not isinstance(bucket, dict): + continue + good, bad = _number(bucket.get("success")), _number(bucket.get("failed")) + time = bucket.get("time") + if good is None or bad is None: + continue + # Only backend clock labels, never arbitrary text from a bucket. + if not isinstance(time, str) or not re.fullmatch(r"(?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d", time): + time = "unknown" + buckets.append({"time": time, "success": good, "failed": bad}) + return {"success": success, "failed": failed, "recent_requests": buckets} + + +def _section(availability="unknown", source="", error=""): + return {"availability": availability, "source": source, "error": error, + "records": [], "retained": 0, "omitted": 0, "invalid": 0} + + +def _fetch(api, paths): + for path in paths: + try: + return api(path, timeout=TIMEOUT), _section("available", path) + except Exception as exc: + # Exception bodies and URLs can contain keys, auth filenames or upstream responses. + code = getattr(exc, "code", None) + if isinstance(exc, HTTPError): + exc.close() + if code in (404, 405, 501): + continue + message = "Telemetry request failed." + if code in (401, 403): + message = "Telemetry access was denied." + elif code == 429: + message = "Telemetry endpoint is rate-limited." + return None, _section("unavailable", path, message) + return None, _section("unsupported", error="No supported read-only telemetry endpoint was found.") + + +def _finish(section, records, invalid, omitted): + section.update(records=records, retained=len(records), invalid=invalid, omitted=omitted) + # Invalid records are not proof of zero activity. + if invalid and not records: + section.update(availability="unknown", error="Telemetry schema was not recognized.") + return section + + +def _usage(payload, section, salt): + if not isinstance(payload, dict) or "error" in payload: + return _finish(section, [], 1, 0) + records, invalid, omitted, inspected = [], 0, 0, 0 + for provider, entries in payload.items(): + if inspected >= MAX_RECORDS: + omitted += len(entries) if isinstance(entries, dict) else 1 + continue + if not isinstance(entries, dict): + invalid += 1 + inspected += 1 + continue + for composite, raw in entries.items(): + if inspected >= MAX_RECORDS: + omitted += 1 + continue + inspected += 1 + counter = _counter(raw) + identity = _label("upstream-key", str(provider) + "\0" + composite, salt) if isinstance(composite, str) else None + if counter is None or identity is None: + invalid += 1 + continue + records.append(dict(counter, label=identity, provider=_provider(provider, salt))) + return _finish(section, records, invalid, omitted) + + +def _accounts(payload, section, salt): + if not isinstance(payload, list): + return _finish(section, [], 1, 0) + records, invalid = [], 0 + for raw in payload[:MAX_RECORDS]: + counter = _counter(raw) + if counter is None: + invalid += 1 + continue + identity = _label("account", raw.get("auth_index"), salt) + if identity is None: + # Auth IDs can be filenames; hashing them is safe but does not establish request attribution. + identity = _label("account", raw.get("id"), salt) + if identity is None: + invalid += 1 + continue + records.append(dict(counter, label=identity, provider=_provider(raw.get("provider", raw.get("type")), salt))) + return _finish(section, records, invalid, max(0, len(payload) - MAX_RECORDS)) + + +def snapshot(api, *, accounts=None, salt=None, consume_queue=False): + """Call GET-only adapter api(full_path, timeout=2), return display-safe metadata. + + Pass raw auth-file records through accounts to reuse an existing backend fetch. + Pass a private installation salt for stable labels across bridge invocations. + consume_queue=True is an explicit destructive capture: other queue consumers + lose these records. No history is persisted. Never enable this for polling. + No raw payloads, keys, arbitrary text, log files or exception strings are returned. + """ + salt = _salt(salt) + payload, usage = _fetch(api, _USAGE_PATHS) + if usage["availability"] == "available": + usage = _usage(payload, usage, salt) + if accounts is None: + payload, account_section = _fetch(api, _ACCOUNT_PATHS) + accounts = payload.get("files") if isinstance(payload, dict) else None + else: + account_section = _section("available", "existing-account-snapshot") + if account_section["availability"] == "available": + account_section = _accounts(accounts, account_section, salt) + queue = {"availability": "read_only_unavailable", "source": "", "events": [], "error": _QUEUE_NOTE} + if consume_queue is True: + items, result = _fetch(api, _QUEUE_PATHS) + queue.update(availability=result["availability"], source=result["source"], error=result["error"]) + if result["availability"] == "available": + if isinstance(items, list): + queue["events"] = sanitize_events(items, salt=salt) + queue["retained"] = len(queue["events"]) + queue["omitted"] = max(0, len(items) - MAX_EVENTS) + queue["invalid"] = min(len(items), MAX_EVENTS) - queue["retained"] + if queue["invalid"] and not queue["retained"]: + queue.update(availability="unknown", error="Captured records did not match the supported telemetry schema.") + else: + queue.update(availability="unknown", error="Telemetry schema was not recognized.") + queue["capture_requested"] = True + queue["consumed"] = result["availability"] == "available" + queue["warning"] = "Capture removes up to 50 pending records. Other collectors cannot read them afterwards." + return {"usage": usage, "accounts": account_section, "queue": queue, + "client_attribution": "receipt_fields_only" if any("client_label" in event for event in queue["events"]) else "unavailable", + "limitations": list(_LIMITATIONS)} + + +def sanitize_events(items, *, salt=None): + """Redact records already supplied by an explicitly authorized queue consumer. + + This function performs no I/O; snapshot calls it only for explicit capture. Unknown fields, + failure bodies, prompts, tool data, headers and raw identifier values are dropped. + Model names are anonymous because arbitrary backend model strings can carry secrets. + """ + if not isinstance(items, list): + return [] + salt, events = _salt(salt), [] + for raw in items[-MAX_EVENTS:]: + if not isinstance(raw, dict): + continue + event = {} + for kind, field in (("client", "api_key"), ("account", "auth_index"), + ("request", "request_id"), ("execution", "execution_id"), ("model", "model")): + label = _label(kind, raw.get(field), salt) + if label: + event[kind + "_label"] = label + event["provider"] = _provider(raw.get("provider"), salt) + timestamp = raw.get("timestamp") + if isinstance(timestamp, str) and len(timestamp) <= 40: + try: + parsed = datetime.fromisoformat(timestamp.replace("Z", "+00:00")) + if parsed.tzinfo is not None: + event["timestamp"] = parsed.isoformat() + except ValueError: + pass + if type(raw.get("failed")) is bool: + event["outcome"] = "failed" if raw["failed"] else "success" + for field in ("latency_ms", "ttft_ms"): + value = _number(raw.get(field)) + if value is not None: + event[field] = value + tokens = raw.get("tokens") + if isinstance(tokens, dict): + event["tokens"] = {field: _number(tokens[field]) for field in _TOKENS + if field in tokens and _number(tokens[field]) is not None} + fail = raw.get("fail") + if isinstance(fail, dict): + status = _number(fail.get("status_code")) + if status is not None and 100 <= status <= 599: + event["status_code"] = status + if len(event) > 1 or event["provider"] != "unknown": + events.append(event) + return events diff --git a/scripts/omaproxy.py b/scripts/omaproxy.py index 8d80d1b..d3f3a5f 100644 --- a/scripts/omaproxy.py +++ b/scripts/omaproxy.py @@ -39,6 +39,7 @@ METADATA_MAX_BYTES = 128 * 1024 EXPANDED_ARCHIVE_MAX_BYTES = 66 * 1024 * 1024 DOWNLOAD_CHUNK_BYTES = 64 * 1024 +REQUEST_MAX_BYTES = 2 * 1024 * 1024 RELEASE_MEMBERS = {"cli-proxy-api", "LICENSE", "README.md", "README_CN.md", "config.example.yaml"} PROVIDERS = [ ("claude", "Claude", "claude-login"), @@ -93,14 +94,25 @@ def request(url, key=None, method="GET", body=None, timeout=4): # Local control traffic must never leave via HTTP_PROXY/HTTPS_PROXY. opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) with opener.open(req, timeout=timeout) as response: - return json.load(response) + raw = response.read(REQUEST_MAX_BYTES + 1) + if len(raw) > REQUEST_MAX_BYTES: + raise ValueError("Proxy response exceeds the JSON size limit.") + return json.loads(raw) def api(route, method="GET", body=None, timeout=4): cfg = settings() if not cfg: raise ValueError("Set up the proxy first.") - return request(f'http://127.0.0.1:{cfg["port"]}/v0/management/{route}', + if method != "GET" and (DATA / "backend-pending").exists(): + raise ValueError("An interrupted backend update needs recovery. Run backend-update or backend-rollback before changing settings or keys.") + if route.startswith("/"): + if not route.startswith(("/v0/management/", "/v8/management/")) or "#" in route: + raise ValueError("Use a supported management API path.") + path = route + else: + path = "/v0/management/" + route + return request(f'http://127.0.0.1:{cfg["port"]}{path}', cfg["management_key"], method, body, timeout=timeout) @@ -402,7 +414,7 @@ def read_json(path, fallback): return fallback -def quota_snapshot(force=False): +def quota_snapshot(force=False, notify=False): import quotas path = CONFIG / "quotas.json" cached = read_json(path, {"accounts": []}) @@ -439,7 +451,11 @@ def refresh_account(account): accounts = list(pool.map(refresh_account, files)) result = {"accounts": accounts, "checked_at": time.time()} private_write(path, json.dumps(result) + "\n") - return {"quotas": result} + response = {"quotas": result} + if notify: + import quota_alerts + response["alerts"] = quota_alerts.process(result, CONFIG) + return response AUTH_ROUTES = {"claude": "anthropic", "codex": "codex", "antigravity": "antigravity", @@ -515,7 +531,7 @@ def logs_snapshot(): def custom_provider(payload): CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) - with (CONFIG / "providers.lock").open("w") as lock: + with (CONFIG / "management.lock").open("w") as lock: fcntl.flock(lock, fcntl.LOCK_EX) return _custom_provider(payload) @@ -552,14 +568,24 @@ def main(): p.add_argument("--binary", help="Use a local CLIProxyAPI or Plus executable") p.add_argument("--port", type=int, default=8317) for name in ("status", "start", "stop", "restart", "dashboard", "logs", "config", "logs-view", - "auth-status", "auth-cancel", "auth-open", "auth-callback", "custom-add", "preferences", "repair"): + "auth-status", "auth-cancel", "auth-open", "auth-callback", "custom-add", "preferences", "repair", + "diagnostics", "capture-activity", "routing-save", "custom-list", "custom-save", "client-keys"): sub.add_parser(name) + for name in ("client-create", "client-revoke", "client-copy"): + p = sub.add_parser(name) + p.add_argument("name") + for name in ("custom-remove", "custom-test"): + p = sub.add_parser(name) + p.add_argument("name") + if name == "custom-test": + p.add_argument("--credential-index", type=int, default=0) p = sub.add_parser("quotas") p.add_argument("--force", action="store_true") + p.add_argument("--notify", action="store_true", help="Opt in to deduplicated desktop quota alerts") p = sub.add_parser("auth-start") p.add_argument("provider", choices=list(AUTH_ROUTES)) p = sub.add_parser("routing") - p.add_argument("strategy", choices=["round-robin", "fill-first"]) + p.add_argument("strategy", choices=["round-robin", "weighted-round-robin", "fill-first"]) p = sub.add_parser("autostart") p.add_argument("value", choices=["on", "off"]) p = sub.add_parser("login") @@ -582,7 +608,54 @@ def main(): changed = repair_service() result = {"message": "Service repaired." if changed else "Service needs no repair."} elif args.action == "quotas": - result = quota_snapshot(args.force) + result = quota_snapshot(args.force, args.notify) + elif args.action in ("diagnostics", "capture-activity"): + import diagnostics + import client_keys + cfg = settings() + result = {"diagnostics": diagnostics.snapshot(api, salt=cfg["management_key"], + consume_queue=args.action == "capture-activity")} + try: + named = client_keys.list_keys(api, CONFIG / "client-keys.json", cfg["management_key"]) + labels = {row["key_label"]: row["name"] for row in named["client_keys"]} + for event in result["diagnostics"]["queue"]["events"]: + if event.get("client_label") in labels: + event["client_name"] = labels[event["client_label"]] + except (ValueError, OSError, urllib.error.URLError): + result["diagnostics"]["limitations"].append("Named client labels could not be resolved; anonymous receipts remain available.") + elif args.action in ("client-keys", "client-create", "client-revoke", "client-copy"): + import client_keys + cfg = settings() + key_args = (api, CONFIG / "client-keys.json", cfg["management_key"]) + if args.action == "client-keys": + result = client_keys.list_keys(*key_args, primary_key=cfg["api_key"]) + else: + CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) + with (CONFIG / "management.lock").open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + operation = {"client-create": client_keys.create_key, "client-revoke": client_keys.revoke_key, + "client-copy": client_keys.copy_key}[args.action] + result = operation(*key_args, args.name, primary_key=cfg["api_key"]) + result["message"] = {"client-create": "Client key created. Copy it into the intended client.", + "client-revoke": "Client key revoked.", "client-copy": "Client key copied to clipboard."}[args.action] + elif args.action in ("custom-list", "custom-save", "custom-remove", "custom-test"): + import providers + import routing + if args.action == "custom-list": + result = providers.list_providers(api) + elif args.action == "custom-test": + result = providers.test_provider(api, args.name, request, credential_index=args.credential_index) + else: + CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) + with (CONFIG / "management.lock").open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + if args.action == "custom-remove": + result = providers.remove_provider(api, args.name) + else: + result = providers.upsert_provider(api, json.loads(sys.stdin.readline()), + weights_supported=routing.supports_weights(settings().get("version"))) + result.update(providers.list_providers(api)) + result["provider_weights_supported"] = routing.supports_weights(settings().get("version")) elif args.action.startswith("auth-"): payload = json.loads(sys.stdin.readline()) if args.action == "auth-callback" else None result = auth_action(args.action, getattr(args, "provider", None), payload) @@ -591,10 +664,17 @@ def main(): elif args.action == "logs-view": result = logs_snapshot() elif args.action == "preferences": - result = {"preferences": {"routing": api("routing/strategy").get("strategy", "")}} - elif args.action == "routing": - api("routing/strategy", "PUT", {"value": args.strategy}) - result = {"message": "Routing strategy updated.", "preferences": {"routing": args.strategy}} + import routing + result = {"routing_settings": routing.read_settings(api, settings().get("version"))} + result["preferences"] = {"routing": result["routing_settings"]["values"].get("strategy", "")} + elif args.action in ("routing", "routing-save"): + import routing + payload = {"strategy": args.strategy} if args.action == "routing" else json.loads(sys.stdin.readline()) + CONFIG.mkdir(parents=True, exist_ok=True, mode=0o700) + with (CONFIG / "management.lock").open("w") as lock: + fcntl.flock(lock, fcntl.LOCK_EX) + result = routing.update_settings(api, payload, settings().get("version")) + result["preferences"] = {"routing": result["routing_settings"]["values"].get("strategy", "")} elif args.action in ("start", "stop", "restart"): systemctl(args.action) result = {"message": f"Proxy {args.action} requested."} diff --git a/scripts/preview-plugin.py b/scripts/preview-plugin.py new file mode 100644 index 0000000..3c5dfab --- /dev/null +++ b/scripts/preview-plugin.py @@ -0,0 +1,462 @@ +#!/usr/bin/env python3 +"""Render checkout QML using installed Omarchy components and an offline bridge.""" +import argparse +import json +import os +from pathlib import Path +import shutil +import subprocess +import tempfile +import time +from urllib.parse import quote + +REPO = Path(__file__).resolve().parents[1] +SHELL = r'''import QtQuick +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import qs.Commons + +ShellRoot { + id: preview + function descendants() { + var found = [], seen = [] + function visit(node) { + if (!node || seen.indexOf(node) >= 0) return + seen.push(node); found.push(node) + for (var key of ["data", "children", "contentItem"]) { + var value = node[key] + if (!value) continue + if (value.length !== undefined) { + for (var i = 0; i < value.length; i++) visit(value[i]) + } else visit(value) + } + } + visit(widget) + return found + } + QtObject { + id: host + property string position: "top" + property bool vertical: false + property int barSize: Style.bar.sizeHorizontal + property color foreground: Color.foreground + property color barForeground: Color.foreground + property color urgent: Color.urgent + property string fontFamily: Style.font.family + property bool foregroundAnimationEnabled: true + property var activePopout: null + property var clickTargets: [] + property QtObject shell: QtObject { + function updateEntryInline(name, settings) { console.log("Preview display setting changed") } + } + function requestPopout(item) { activePopout = item } + function releasePopout(item) { if (activePopout === item) activePopout = null } + function registerClickTarget(item) { clickTargets = clickTargets.concat([item]) } + function unregisterClickTarget(item) { clickTargets = clickTargets.filter(function(v) { return v !== item }) } + function showTooltip(item, text) {} + function hideTooltip(item) {} + function switchPanelFrom(item, direction) { return false } + } + PanelWindow { + id: barWindow + anchors { top: true; left: true; right: true } + implicitHeight: host.barSize + exclusiveZone: 0 + exclusionMode: ExclusionMode.Ignore + WlrLayershell.namespace: "omaproxy-isolated-preview" + WlrLayershell.layer: WlrLayer.Overlay + color: Color.background + Text { anchors.left: parent.left; anchors.leftMargin: 12; anchors.verticalCenter: parent.verticalCenter; text: "OmaProxy offline preview"; color: Color.foreground } + BarWidget { id: widget; bar: host; anchors.horizontalCenter: parent.horizontalCenter; anchors.verticalCenter: parent.verticalCenter } + } + IpcHandler { + target: "omaproxy-preview" + function state(): string { + return JSON.stringify({opened: widget.opened, page: widget.page, busy: widget.busy, + running: widget.snapshot.running, notice: widget.notice, noticeError: widget.noticeError, + revealedEmails: Object.keys(widget.revealedEmails).length, + updates: "updates" in widget ? widget.updates : {}, + routing: "routingSettings" in widget ? widget.routingSettings : {}, + diagnostics: "diagnostics" in widget ? widget.diagnostics : {}, + customProviders: "customProviders" in widget ? widget.customProviders : [], + providerWeightsSupported: "providerWeightsSupported" in widget ? widget.providerWeightsSupported : false, + clientKeys: "clientKeys" in widget ? widget.clientKeys : [], + revokingClient: "revokingClient" in widget ? widget.revokingClient : "", + quotaAlerts: "quotaAlerts" in widget ? widget.quotaAlerts : false, + mode: widget.snapshot.mode, hasApiKey: widget.snapshot.has_api_key, + editingProvider: "editingProvider" in widget ? widget.editingProvider : "", + addingKey: widget.addingKey, + pageRefreshPending: "pageRefreshPending" in widget ? widget.pageRefreshPending : false, + removingProvider: "removingProvider" in widget ? widget.removingProvider : "", + passwordFieldsCleared: preview.descendants().filter(function(item) { + return item.password === true && item.text !== undefined + }).every(function(item) { return item.text === "" })}) + } + function controls(): string { + return JSON.stringify(preview.descendants().filter(function(item) { + return item.text !== undefined && typeof item.clicked === "function" + }).map(function(item) { return {text: item.text, visible: item.visible, enabled: item.enabled} })) + } + function hasText(text: string): bool { + return preview.descendants().some(function(item) { return item.text === text && item.visible }) + } + function containsText(text: string): bool { + return preview.descendants().some(function(item) { return typeof item.text === "string" && item.text.indexOf(text) >= 0 && item.visible }) + } + function activate(text: string): string { + var matches = preview.descendants().filter(function(item) { + return item.text === text && item.visible && item.enabled && typeof item.clicked === "function" + }) + if (matches.length !== 1) return "Expected one enabled visible control; found " + matches.length + matches[0].forceActiveFocus() + matches[0].clicked() + return "Activated " + text + } + function setField(placeholder: string, encodedText: string): string { + var fields = preview.descendants().filter(function(item) { + return item.placeholderText === placeholder && item.visible && item.enabled + }) + if (fields.length !== 1) return "Expected one editable visible field; found " + fields.length + fields[0].text = decodeURIComponent(encodedText) + return "Field updated" + } + function setNumericField(previous: string, text: string): string { + var fields = preview.descendants().filter(function(item) { + return item.placeholderText !== undefined && item.text === previous && item.visible && item.enabled + }) + if (!fields.length) return "Editable numeric field not found" + fields[0].text = text + return "Field updated" + } + function scrollTo(text: string): string { + var items = preview.descendants().filter(function(item) { return item.text === text && item.visible && item.mapToItem }) + if (!items.length) return "Visible item not found" + var item = items[0], ancestor = item.parent + while (ancestor) { + if (ancestor.contentY !== undefined && ancestor.contentHeight !== undefined && ancestor.contentItem) { + var point = item.mapToItem(ancestor.contentItem, 0, 0) + ancestor.contentY = Math.max(0, Math.min(point.y, ancestor.contentHeight - ancestor.height)) + return "Scrolled to item" + } + ancestor = ancestor.parent + } + return "Item has no scroll ancestor" + } + function capture(path: string): string { + var cards = preview.descendants().filter(function(item) { + return item.borderSpec !== undefined && typeof item.grabToImage === "function" && item.width > 200 + }) + cards.sort(function(a, b) { return b.width * b.height - a.width * a.height }) + if (!cards.length || cards[0].height < 200) return "Popup card was not found" + cards[0].grabToImage(function(result) { + console.log("Preview capture saved: " + result.saveToFile(path)) + }) + return "Capturing popup card" + } + function quit(): void { Qt.quit() } + } +} +''' + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--page", choices=("limits", "accounts", "settings"), default="limits") + parser.add_argument("--duration", type=float, help="Stop automatically after this many seconds.") + parser.add_argument("--keep", action="store_true", help="Keep private temporary fixture and log files after exit.") + parser.add_argument("--smoke", action="store_true", help="Exercise detected native controls against fixtures and capture the concealed account card.") + parser.add_argument("--repo", type=Path, default=REPO, help="Checkout to preview; its real helper is never copied or executed.") + args = parser.parse_args() + if args.smoke: + args.page = "settings" + runtime = shutil.which("quickshell") + packaged = Path("/usr/share/omarchy/shell") + if not runtime or not (packaged / "Ui" / "KeyboardPanel.qml").exists(): + parser.error("Requires installed Omarchy shell and Quickshell.") + root = Path(tempfile.mkdtemp(prefix="omaproxy-preview-")) + child = None + try: + for directory in ("Commons", "Ui"): + (root / directory).symlink_to(packaged / directory, target_is_directory=True) + for file in ("BarWidget.qml", "LimitModel.js"): + shutil.copy2(args.repo / file, root / file) + shutil.copytree(args.repo / "assets", root / "assets") + (root / "scripts").mkdir() + shutil.copy2(REPO / "tests/fixtures/preview_bridge.py", root / "scripts/omaproxy.py") + (root / "shell.qml").write_text(SHELL) + state = root / "fixture-state" + state.mkdir(mode=0o700) + env = os.environ.copy() + env.update(OMAPROXY_PREVIEW_STATE=str(state), QT_LINUX_ACCESSIBILITY_ALWAYS_ON="1", QT_ACCESSIBILITY="1", OMAPROXY_PREVIEW_STATUS_DELAY="2" if args.smoke else "0") + print(json.dumps({"preview_root": str(root), "log": str(root / "quickshell.log"), "action_trace": str(state / "actions.jsonl"), "ipc": [runtime, "ipc", "-p", str(root), "call", "soojy.omaproxy", "showPage", args.page]}), flush=True) + with (root / "quickshell.log").open("w") as log: + child = subprocess.Popen([runtime, "-p", str(root), "--no-color"], env=env, stdout=log, stderr=subprocess.STDOUT) + # IPC is scoped by config path, so it never opens the installed plugin. + for _ in range(50): + if child.poll() is not None: + print((root / "quickshell.log").read_text(), flush=True) + return child.returncode or 1 + ipc = subprocess.run([runtime, "ipc", "-p", str(root), "call", "soojy.omaproxy", "showPage", args.page], capture_output=True, text=True) + if ipc.returncode == 0: + break + time.sleep(0.1) + else: + raise RuntimeError("Preview IPC target did not become ready. See " + str(root / "quickshell.log")) + if args.smoke: + smoke(runtime, root) + child.terminate() + child.wait(timeout=5) + return 0 + try: + return child.wait(timeout=args.duration) + except subprocess.TimeoutExpired: + child.terminate() + child.wait(timeout=5) + return 0 + except KeyboardInterrupt: + return 0 + finally: + if child and child.poll() is None: + child.terminate() + child.wait(timeout=5) + if args.keep: + print("Retained private preview files: " + str(root), flush=True) + else: + shutil.rmtree(root) + + +def smoke(runtime, root): + def ipc(target, function, *args): + return subprocess.run([runtime, "ipc", "-p", str(root), "call", target, function, *args], check=True, capture_output=True, text=True).stdout.strip() + + def wait(predicate): + for _ in range(100): + result = json.loads(ipc("omaproxy-preview", "state")) + if predicate(result): + return result + time.sleep(0.05) + raise RuntimeError("Native preview state did not converge: " + json.dumps(result)) + + def activate(text): + wait(lambda state: not state["busy"] and not state["pageRefreshPending"]) + response = ipc("omaproxy-preview", "activate", text) + if not response.startswith("Activated "): + raise RuntimeError(response) + wait(lambda state: not state["busy"] and not state["pageRefreshPending"]) + + def controls(): + return json.loads(ipc("omaproxy-preview", "controls")) + + def has(text): + return any(item["text"] == text and item["visible"] for item in controls()) + + def field(placeholder, value): + result = ipc("omaproxy-preview", "setField", placeholder, quote(value, safe="")) + if result != "Field updated": + raise RuntimeError(result + ": " + placeholder) + + captures = [] + def capture(name, scroll_text=None): + ipc("soojy.omaproxy", "open") + if scroll_text: + result = ipc("omaproxy-preview", "scrollTo", scroll_text) + if result != "Scrolled to item": + raise RuntimeError(result) + time.sleep(0.25) + path = root / (name + ".png") + response = ipc("omaproxy-preview", "capture", str(path)) + if response != "Capturing popup card": + raise RuntimeError(response) + for _ in range(40): + if path.exists() and path.read_bytes().endswith(b"IEND\xaeB`\x82"): + captures.append(str(path)) + return + time.sleep(0.05) + raise RuntimeError("Native card capture was not saved.") + + required = set() + initial_state = json.loads(ipc("omaproxy-preview", "state")) + cold_settings = "pageRefreshPending" in (root / "BarWidget.qml").read_text() + if cold_settings and (initial_state["running"] or initial_state["page"] != 2): + raise RuntimeError("Cold Settings preview was not observed before initial status") + wait(lambda state: state["running"]) + if cold_settings: + wait(lambda state: state["page"] == 2 and bool(state["routing"].get("values")) and '"command": "preferences"' in (root / "fixture-state/actions.jsonl").read_text()) + activate("Settings") + if has("Check backend updates"): + activate("Check backend updates") + wait(lambda state: state["updates"].get("update_supported") is True and state["updates"].get("update_available") is True) + activate("Install reviewed update") + wait(lambda state: state["updates"].get("installed_version") == "v6.9.22") + activate("Restore previous backend") + wait(lambda state: state["updates"].get("installed_version") == "v6.9.20") + required.update(("check-updates", "backend-update", "backend-rollback")) + capture("updater", "Check backend updates") + if has("Routing details"): + wait(lambda state: bool(state["routing"].get("values"))) + activate("Weighted") + wait(lambda state: state["routing"]["values"].get("strategy") == "weighted-round-robin") + activate("Routing details") + activate("Keep conversations on one account: On") + wait(lambda state: state["routing"]["values"].get("session-affinity") is False) + activate("Subagents inherit the conversation account: On") + wait(lambda state: state["routing"]["values"].get("session-affinity-subagents") is False) + activate("Disable cooldowns: Off") + wait(lambda state: state["routing"]["values"].get("disable-cooling") is True) + activate("Persist cooldown state: On") + wait(lambda state: state["routing"]["values"].get("save-cooldown-status") is False) + field("Conversation affinity duration, e.g. 1h", "2h") + activate("Save affinity duration") + wait(lambda state: state["routing"]["values"].get("session-affinity-ttl") == "2h") + for previous, value in (("2", "3"), ("2", "4"), ("30", "45")): + if ipc("omaproxy-preview", "setNumericField", previous, value) != "Field updated": + raise RuntimeError("Retry field update failed") + activate("Save retry limits") + wait(lambda state: all(state["routing"]["values"].get(k) == v for k, v in (("request-retry", 3), ("max-retry-credentials", 4), ("max-retry-interval", 45)))) + for previous, invalid in (("3", ""), ("4", "1.5")): + prior_trace = (root / "fixture-state/actions.jsonl").read_text().count('"command": "routing-save"') + if ipc("omaproxy-preview", "setNumericField", previous, invalid) != "Field updated": + raise RuntimeError("Retry invalid-input fixture failed") + activate("Save retry limits") + wait(lambda state: state["noticeError"]) + if (root / "fixture-state/actions.jsonl").read_text().count('"command": "routing-save"') != prior_trace: + raise RuntimeError("Invalid retry input reached the fixture bridge") + if ipc("omaproxy-preview", "setNumericField", invalid, previous) != "Field updated": + raise RuntimeError("Retry field restore failed") + activate("Save retry limits") + wait(lambda state: not state["noticeError"]) + capture("routing", "Keep conversations on one account: Off") + activate("Hide routing details") + activate("Quota alerts: Off") + wait(lambda state: state["quotaAlerts"]) + activate("Limits") + activate("Refresh") + wait(lambda state: state["noticeError"] and "Preview alert delivery failed" in state["notice"]) + capture("alert-error") + activate("Settings") + activate("Quota alerts: On") + wait(lambda state: not state["quotaAlerts"]) + if has("Named client keys"): + activate("Named client keys") + field("Client name, e.g. t3-code or codex-cli", "preview-t3-code") + activate("Create client key") + wait(lambda state: len(state["clientKeys"]) == 1 and state["clientKeys"][0]["active"]) + activate("Refresh client keys") + activate("Copy client key") + wait(lambda state: state["notice"] == "Preview client key copy recorded; clipboard unchanged.") + capture("client-keys", "Refresh client keys") + required.update(("client-keys", "client-create", "client-copy", "client-revoke")) + activate("Show diagnostics") + wait(lambda state: bool(state["diagnostics"].get("accounts", {}).get("records"))) + activate("Refresh counters") + activate("Capture pending activity") + wait(lambda state: bool(state["diagnostics"].get("queue", {}).get("events"))) + for summary in ("Accounts: available · 1 shown · 2 unrecognized · 3 omitted", + "Upstream keys: available · 1 shown · 1 unrecognized · 2 omitted", + "Activity: available · 1 shown · 2 unrecognized · 4 omitted"): + if ipc("omaproxy-preview", "hasText", summary) != "true": + raise RuntimeError("Missing displayed diagnostic summary: " + summary) + if has("Hide client keys"): + wait(lambda state: state["diagnostics"]["queue"]["events"][0].get("client_name") == "preview-t3-code") + if "diagnosticEventDetails" in (root / "BarWidget.qml").read_text(): + for detail in ("2026-10-03T18:00:00Z", "model-preview", "HTTP 200", "First token 40 ms", "input tokens: 100", "output tokens: 23", "cached tokens: 0", "total tokens: 123"): + if ipc("omaproxy-preview", "containsText", detail) != "true": + raise RuntimeError("Captured activity metadata was not rendered: " + detail) + capture("diagnostics", "Refresh counters") + capture("activity-detail", "Activity: available · 1 shown · 2 unrecognized · 4 omitted") + if has("Hide client keys"): + activate("Revoke") + wait(lambda state: state["revokingClient"] == "preview-t3-code") + prior_trace = (root / "fixture-state/actions.jsonl").read_text().count('"command": "client-revoke"') + activate("Accounts") + wait(lambda state: not state["revokingClient"]) + if (root / "fixture-state/actions.jsonl").read_text().count('"command": "client-revoke"') != prior_trace: + raise RuntimeError("Staged revocation reached the fixture bridge") + activate("Settings") + activate("Revoke") + activate("Confirm revocation") + wait(lambda state: not state["clientKeys"]) + capture("client-keys-revoked", "Refresh client keys") + activate("Hide client keys") + required.update(("routing", "routing-save", "diagnostics", "capture-activity")) + if has("Remote"): + activate("Remote") + field("Server URL, e.g. https://proxy.example.com", "https://proxy.example.invalid") + field("Management key", "preview-management-only") + field("Client API key (optional, for models)", "preview-client-only") + activate("Test and save connection") + wait(lambda state: state["mode"] == "remote" and state["hasApiKey"]) + capture("remote-connected", "Remote") + activate("Remove saved client API key") + activate("Test and save connection") + wait(lambda state: state["mode"] == "remote" and state["hasApiKey"] is False) + capture("remote-management-only", "Remote") + activate("Local") + wait(lambda state: state["mode"] == "local") + required.update(("connection-save", "connection-local")) + activate("Accounts") + if has("Refresh providers"): + wait(lambda state: bool(state["customProviders"])) + wait(lambda state: state["providerWeightsSupported"]) + activate("Test models") + activate("Edit") + wait(lambda state: state["editingProvider"] == "preview-provider") + field("Base URL, e.g. https://provider.example/v1", "https://provider.example.invalid/v2") + activate("Save provider") + wait(lambda state: state["customProviders"][0]["url"] == "https://provider.example.invalid/v2") + saves = [json.loads(line) for line in (root / "fixture-state/actions.jsonl").read_text().splitlines() if json.loads(line)["command"] == "custom-save"] + if "models" in saves[-1]["payload_fields"] or "url" not in saves[-1]["payload_fields"]: + raise RuntimeError("URL-only provider edit did not omit models") + if json.loads(ipc("omaproxy-preview", "state"))["customProviders"][0]["models"][0]["alias"] != "preview-model": + raise RuntimeError("URL-only provider edit changed aliases") + wait(lambda state: not state["addingKey"] and not state["editingProvider"]) + activate("Edit") + capture("provider-url-only", "Save provider") + field("Model IDs, or JSON with name and alias", '[{"name":"preview-upstream","alias":"preview-edited"}]') + field("Optional weight (0 excludes this credential)", "2") + activate("Save provider") + wait(lambda state: state["customProviders"][0]["models"][0].get("alias") == "preview-edited") + wait(lambda state: state["customProviders"][0]["credential_count"] == 2 and state["customProviders"][0]["credentials"][0]["weight"] == 2) + wait(lambda state: not state["addingKey"] and not state["editingProvider"]) + activate("Edit") + capture("provider-edit", "Save provider") + activate("Hide API provider form") + activate("Remove") + wait(lambda state: state["removingProvider"] == "preview-provider") + # A page change must cancel the staged destructive confirmation. + activate("Settings") + wait(lambda state: not state["removingProvider"]) + activate("Accounts") + activate("Remove") + activate("Confirm removal") + wait(lambda state: not state["customProviders"]) + activate("+ API-key provider") + field("Name, e.g. zai", "preview-new-provider") + field("Base URL, e.g. https://provider.example/v1", "https://provider.example.invalid/v1") + field("API key", "preview-key-only") + field("Model IDs, or JSON with name and alias", '[{"name":"preview-upstream","alias":"preview-new"}]') + activate("Save provider") + wait(lambda state: len(state["customProviders"]) == 1 and state["customProviders"][0]["name"] == "preview-new-provider") + wait(lambda state: not state["addingKey"]) + required.update(("custom-list", "custom-test", "custom-save", "custom-remove")) + prior_refreshes = (root / "fixture-state/actions.jsonl").read_text().count('"command": "custom-list"') + ipc("soojy.omaproxy", "close") + ipc("soojy.omaproxy", "showPage", "accounts") + wait(lambda state: state["opened"] and state["revealedEmails"] == 0 and state["passwordFieldsCleared"]) + if "custom-list" in required: + wait(lambda state: not state["busy"] and (root / "fixture-state/actions.jsonl").read_text().count('"command": "custom-list"') > prior_refreshes) + capture("accounts") + trace = [json.loads(line)["command"] for line in (root / "fixture-state/actions.jsonl").read_text().splitlines()] + if "routing-save" in required and not any(json.loads(line).get("notification_requested") for line in (root / "fixture-state/actions.jsonl").read_text().splitlines()): + raise RuntimeError("Native quota refresh did not pass the alert opt-in flag") + if not required.issubset(trace): + raise RuntimeError("Missing fixture actions: " + repr(required - set(trace))) + log = (root / "quickshell.log").read_text() + if any(marker in log for marker in ("ReferenceError:", "TypeError:", "Unable to load configuration", "failed to load component")): + raise RuntimeError("Native runtime errors in " + str(root / "quickshell.log")) + print(json.dumps({"native_smoke": "passed", "verified_actions": sorted(required), "cold_settings_preferences_before_navigation": cold_settings, "concealed_email_reopen": True, "popup_captures": captures}), flush=True) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/providers.py b/scripts/providers.py new file mode 100644 index 0000000..6c584b5 --- /dev/null +++ b/scripts/providers.py @@ -0,0 +1,238 @@ +"""Custom OpenAI-compatible providers. Caller serializes read/modify/write operations. + +Credentials stay in this module and travel in management request bodies only. +The request callable used for explicit tests must refuse redirects and proxies. +""" +import copy +import re +import urllib.error +import urllib.parse + +ROUTE = "/v0/management/openai-compatibility" +MAX_WEIGHT = 1_000_000 + + +def validate_url(value): + if not isinstance(value, str) or any(ord(c) < 33 for c in value): + raise ValueError("Enter a base URL without whitespace or control characters.") + parsed = urllib.parse.urlsplit(value) + try: + parsed.port + except ValueError as error: + raise ValueError("Enter a valid endpoint port.") from error + local = parsed.hostname in ("localhost", "127.0.0.1", "::1") + if parsed.scheme != "https" and not (parsed.scheme == "http" and local): + raise ValueError("Use an HTTPS endpoint, or an HTTP endpoint on localhost.") + if not parsed.hostname or parsed.username is not None or parsed.password is not None or parsed.query or parsed.fragment or "\\" in value: + raise ValueError("Enter a base URL without credentials, query parameters, or fragments.") + return value.rstrip("/") + + +def _name(value): + if not isinstance(value, str) or not re.fullmatch(r"[a-zA-Z0-9_-]{1,60}", value): + raise ValueError("Use a provider name with letters, numbers, underscores, or hyphens.") + return value + + +def _entries(api): + response = api(ROUTE) + entries = response.get("openai-compatibility") if isinstance(response, dict) else None + if not isinstance(entries, list) or any(not isinstance(entry, dict) for entry in entries): + raise ValueError("The backend returned an unsupported provider list.") + return entries + + +def _target(entries, name): + matches = [entry for entry in entries if entry.get("name") == name] + if len(matches) > 1: + raise ValueError("The backend has duplicate provider names. Resolve them before editing.") + return matches[0] if matches else None + + +def _display_text(value, limit=256): + return value[:limit] if isinstance(value, str) and not any(ord(c) < 32 for c in value) else "" + + +def _public(entry): + # Base URLs may contain credentials in configurations created elsewhere. + try: + url = validate_url(entry.get("base-url", "")) + except ValueError: + url = "" + keys = entry.get("api-key-entries", []) + keys = keys if isinstance(keys, list) else [] + models = entry.get("models", []) + models = models if isinstance(models, list) else [] + return {"name": _display_text(entry.get("name"), 60), "url": url, + "models": [{"name": _display_text(model.get("name")), + "alias": _display_text(model.get("alias"))} + for model in models if isinstance(model, dict)], + "disabled": entry.get("disabled") is True, + "credential_count": len(keys), + "credentials": [{"index": index, + "has_key": bool(key.get("api-key")), + "weight": key.get("weight") if type(key.get("weight")) is int else None} + for index, key in enumerate(keys) if isinstance(key, dict)]} + + +def list_providers(api): + return {"custom_providers": [_public(entry) for entry in _entries(api)]} + + +def _models(value, previous): + if isinstance(value, str): + value = [item.strip() for item in value.split(",") if item.strip()] + if not isinstance(value, list) or not 1 <= len(value) <= 500: + raise ValueError("Enter at least one model ID (at most 500).") + result, aliases = [], set() + for item in value: + if isinstance(item, str): + name, alias = item, item + elif isinstance(item, dict): + name, alias = item.get("name"), item.get("alias") or item.get("name") + else: + raise ValueError("Each model needs a name and optional alias.") + for text in (name, alias): + if not isinstance(text, str) or not text or len(text) > 256 or any(ord(c) < 33 for c in text): + raise ValueError("Use model IDs without whitespace or control characters.") + if alias in aliases: + raise ValueError("Use a distinct alias for each model.") + aliases.add(alias) + old = next((model for model in previous if isinstance(model, dict) and + model.get("name") == name and (model.get("alias") or name) == alias), {}) + model = copy.deepcopy(old) + model.update(name=name, alias=alias) + result.append(model) + return result + + +def upsert_provider(api, payload, *, weights_supported=False, create_only=False): + if not isinstance(payload, dict): + raise ValueError("Provide a provider object.") + if set(payload) - {"name", "url", "key", "models", "weight", "credential_index", "disabled"}: + raise ValueError("Unsupported provider field.") + name = _name(payload.get("name")) + entries = _entries(api) + old = _target(entries, name) + if old and create_only: + raise ValueError("That provider already exists. Choose a different name.") + patch = {} + if "url" in payload: + patch["base-url"] = validate_url(payload["url"]) + elif not old: + raise ValueError("Enter a provider base URL.") + if "models" in payload: + patch["models"] = _models(payload["models"], old.get("models", []) if old else []) + elif not old: + raise ValueError("Enter at least one model ID.") + if "disabled" in payload: + if type(payload["disabled"]) is not bool: + raise ValueError("disabled must be a boolean.") + patch["disabled"] = payload["disabled"] + key = payload.get("key", "") + if not isinstance(key, str) or any(ord(c) < 32 for c in key): + raise ValueError("Enter a valid API key.") + key = key.strip() + if "weight" in payload: + weight = payload["weight"] + if not weights_supported: + raise ValueError("Credential weights are unavailable for this backend version.") + if weight is not None and (type(weight) is not int or not 0 <= weight <= MAX_WEIGHT): + raise ValueError(f"Credential weight must be between 0 and {MAX_WEIGHT}, or null to reset.") + if key or "weight" in payload: + keys = copy.deepcopy(old.get("api-key-entries", []) if old else []) + index = payload.get("credential_index", 0) + if type(index) is not int or index < 0: + raise ValueError("Choose a valid credential index.") + if len(keys) > 1 and "credential_index" not in payload: + raise ValueError("Choose which provider credential to edit.") + if not keys and index == 0: + keys.append({}) + if index >= len(keys): + raise ValueError("That provider credential does not exist.") + if key: + keys[index]["api-key"] = key + if "weight" in payload: + if payload["weight"] is None: + keys[index].pop("weight", None) + else: + keys[index]["weight"] = payload["weight"] + # auth_index is a backend display identifier, never stored config. + for credential in keys: + credential.pop("auth_index", None) + credential.pop("auth-index", None) + patch["api-key-entries"] = keys + elif not old: + patch["api-key-entries"] = [] + effective_url = validate_url(patch.get("base-url", old.get("base-url", "") if old else "")) + effective_keys = patch.get("api-key-entries", old.get("api-key-entries", []) if old else []) + if not isinstance(effective_keys, list) or any(not isinstance(item, dict) for item in effective_keys): + raise ValueError("The backend returned an unsupported provider credential list.") + has_key = any(isinstance(item.get("api-key"), str) and item["api-key"].strip() for item in effective_keys) + if urllib.parse.urlsplit(effective_url).hostname not in ("localhost", "127.0.0.1", "::1") and not has_key: + raise ValueError("Enter an API key for a remote provider.") + if old: + if not patch: + raise ValueError("Provide at least one provider change.") + # Omission of api-key-entries preserves ALL credentials when key is blank. + api(ROUTE, "PATCH", {"name": name, "value": patch}) + else: + new = {"name": name, **patch} + # v0 has no append operation. Never replace unrelated definitions when + # editing/removing; only creation needs a locked array read + PUT. + api(ROUTE, "PUT", copy.deepcopy(entries) + [new]) + confirmed = _target(_entries(api), name) + if not confirmed: + raise ValueError("Backend did not confirm the provider. Refresh before retrying.") + for field in ("base-url", "models", "disabled"): + if field in patch and confirmed.get(field, False if field == "disabled" else None) != patch[field]: + raise ValueError("Backend did not confirm all provider changes. Refresh before retrying.") + if "api-key-entries" in patch: + confirmed_keys = copy.deepcopy(confirmed.get("api-key-entries", [])) + for credential in confirmed_keys: + credential.pop("auth_index", None) + credential.pop("auth-index", None) + if confirmed_keys != patch["api-key-entries"]: + raise ValueError("Backend did not confirm the credential changes. Refresh before retrying.") + return {"message": "API provider updated." if old else "API provider added. Its models are now available.", + "custom_provider": _public(confirmed)} + + +def remove_provider(api, name): + name = _name(name) + if not _target(_entries(api), name): + raise ValueError("That provider does not exist.") + api(ROUTE + "?" + urllib.parse.urlencode({"name": name}), "DELETE") + if _target(_entries(api), name): + raise ValueError("Backend did not confirm provider removal. Refresh before retrying.") + return {"message": "API provider removed.", "custom_providers": list_providers(api)["custom_providers"]} + + +def test_provider(api, name, request_fn, *, credential_index=0): + """Explicit GET /models only. Never infer a model or make a completion call.""" + name = _name(name) + entry = _target(_entries(api), name) + if entry is None: + raise ValueError("That provider does not exist.") + url = validate_url(entry.get("base-url", "")) + # Arbitrary configured authorization/header overrides are intentionally not + # copied to the request; those providers require a backend-specific check. + if entry.get("headers"): + raise ValueError("Provider tests with custom headers are unsupported.") + keys = entry.get("api-key-entries", []) + if type(credential_index) is not int or credential_index < 0 or (keys and credential_index >= len(keys)) or (not keys and credential_index != 0): + raise ValueError("Choose a valid credential index.") + key = keys[credential_index].get("api-key") if keys else None + try: + result = request_fn(url + "/models", key=key, method="GET", timeout=8) + except urllib.error.HTTPError as error: + code = error.code + error.close() + raise ValueError(f"Provider model discovery returned HTTP {code}.") from None + except Exception: + raise ValueError("Provider model discovery failed.") from None + if not isinstance(result, dict) or not isinstance(result.get("data"), list): + raise ValueError("Provider returned an unsupported model list.") + ids = [_display_text(model.get("id")) for model in result["data"] if isinstance(model, dict)] + return {"message": "Provider model discovery succeeded. Inference was not tested.", + "provider_test": {"name": name, "models": [value for value in ids if value], "inference_tested": False}} diff --git a/scripts/quota_alerts.py b/scripts/quota_alerts.py new file mode 100644 index 0000000..2764618 --- /dev/null +++ b/scripts/quota_alerts.py @@ -0,0 +1,458 @@ +"""Private, opt-in desktop alerts for fresh OmaProxy quota snapshots.""" +import fcntl +import hashlib +import json +import math +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import time + + +MAX_DATA_AGE_SECONDS = 10 * 60 +FUTURE_SKEW_SECONDS = 30 +MAX_STATE_ENTRIES = 200 +MAX_STATE_BYTES = 256 * 1024 +STATE_RETENTION_SECONDS = 90 * 24 * 60 * 60 +LOW_REMAINING_PERCENT = 10 + +_PROVIDERS = { + "antigravity": "Antigravity", + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini", + "gemini-cli": "Gemini", + "github-copilot": "GitHub Copilot", + "kimi": "Kimi", + "qwen": "Qwen", + "xai": "xAI", +} +_PROVIDER_LABELS = set(_PROVIDERS.values()) | {"Provider"} +_AUTH_FAILURE_STATUSES = {"auth_error", "authentication_error", "expired", "unauthorized"} +_HEALTHY_STATUSES = {"active", "authenticated", "healthy", "ok", "ready", "success", "valid"} +_ENV_ALLOWLIST = ("DBUS_SESSION_BUS_ADDRESS", "DISPLAY", "LANG", "LC_ALL", "LC_MESSAGES", + "WAYLAND_DISPLAY", "XDG_RUNTIME_DIR") +_HEX_DIGEST = re.compile(r"^[0-9a-f]{64}$") +_DURATION_LABEL = re.compile(r"^(\d+(?:\.\d+)?)\s*(?:-|\s)?(hour|hours|minute|minutes)$", re.I) + + +def _number(value): + if value is None or isinstance(value, bool): + return None + try: + result = float(value) + except (TypeError, ValueError, OverflowError): + return None + return result if math.isfinite(result) else None + + +def _is_fresh(value, now): + stamp = _number(value) + return (stamp is not None and stamp > 0 and stamp <= now + FUTURE_SKEW_SECONDS + and now - stamp <= MAX_DATA_AGE_SECONDS) + + +def _digest(value): + return hashlib.sha256(value.encode("utf-8", "replace")).hexdigest() + + +def _account_identity(account): + value = account.get("auth_index") or account.get("name") + if isinstance(value, (str, int)) and not isinstance(value, bool) and str(value).strip(): + return _digest(str(value)) + return None + + +def _provider_label(account): + raw = account.get("provider") or account.get("type") + if not isinstance(raw, str): + return "Provider" + return _PROVIDERS.get(raw.strip().casefold(), "Provider") + + +def _window_label(value): + """Map known quota labels to generic standard labels; never echo provider text.""" + if not isinstance(value, str) or len(value) > 160: + return None + candidate = value.strip().rsplit("·", 1)[-1].strip() + key = candidate.casefold().replace("_", " ").strip() + aliases = { + "weekly": "Weekly", "7 day": "Weekly", "7-day": "Weekly", "7 days": "Weekly", + "monthly": "Monthly", "primary window": "Primary window", + "secondary window": "Secondary window", "quota": "Quota", + } + if key in aliases: + return aliases[key] + match = _DURATION_LABEL.fullmatch(key) + if match: + value = float(match.group(1)) + if math.isfinite(value) and value > 0 and value <= 10000: + amount = f"{value:g}" + unit = "hour" if match.group(2).casefold().startswith("hour") else "minute" + return f"{amount}-{unit}" + return None + + +def _status(value): + if not isinstance(value, str): + return "" + return re.sub(r"[\s-]+", "_", value.strip().casefold()) + + +def _event_id(account_digest, kind, window, generation): + return _digest("\0".join((account_digest, kind, window, str(generation)))) + + +def _observation_id(account_digest, window): + return _digest("\0".join((account_digest, "observation", window))) + + +def _window_id(value): + if not isinstance(value, str) or not value.strip() or len(value) > 160: + return None + # Keep otherwise-identically-labeled provider windows distinct without + # retaining or exposing their raw provider-supplied names. + return _digest(" ".join(value.casefold().split())) + + +def _state_paths(config_path): + directory = Path(config_path).expanduser() + # A mistaken caller must not leave persistent alert state in a checkout. + repository = Path(__file__).resolve().parents[1] + try: + directory.resolve().relative_to(repository) + except ValueError: + pass + else: + raise ValueError("Alert state must be stored outside the repository.") + return directory / "quota-alerts.json", directory / "quota-alerts.lock" + + +def _clean_entry(entry): + if not isinstance(entry, dict): + return None + kind = entry.get("kind") + if kind not in {"observation", "low", "reset", "authfailure"}: + return None + account_digest = entry.get("account") + if not isinstance(account_digest, str) or not _HEX_DIGEST.fullmatch(account_digest): + return None + touched = _number(entry.get("touched")) + if touched is None or touched <= 0: + return None + result = {"kind": kind, "account": account_digest, "touched": touched} + if kind == "observation": + window = _window_label(entry.get("window")) + window_id = entry.get("window_id") + remaining = _number(entry.get("remaining")) + generation = entry.get("generation") + if (window is None or not isinstance(window_id, str) or not _HEX_DIGEST.fullmatch(window_id) + or remaining is None or not 0 <= remaining <= 100): + return None + if not isinstance(generation, str) or len(generation) > 32: + return None + reset_at = _number(entry.get("reset_at")) + result.update(window=window, window_id=window_id, remaining=remaining, generation=generation, + reset_at=reset_at) + return result + + delivery = entry.get("delivery") + if delivery not in {"pending", "sent"}: + return None + provider = entry.get("provider") + if provider not in _PROVIDER_LABELS: + return None + result.update(delivery=delivery, provider=provider, + created=_number(entry.get("created")) or touched) + if kind != "authfailure": + window = _window_label(entry.get("window")) + window_id = entry.get("window_id") + if window is None or not isinstance(window_id, str) or not _HEX_DIGEST.fullmatch(window_id): + return None + result.update(window=window, window_id=window_id) + generation = entry.get("generation") + if not isinstance(generation, str) or len(generation) > 32: + return None + result["generation"] = generation + if kind == "low": + remaining = _number(entry.get("remaining")) + if remaining is None or not 0 <= remaining <= LOW_REMAINING_PERCENT: + return None + result["remaining"] = remaining + return result + + +def _load_state(path): + try: + if path.stat().st_size > MAX_STATE_BYTES: + return {"entries": {}, "unsupported_reported": False} + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, ValueError): + return {"entries": {}, "unsupported_reported": False} + if not isinstance(data, dict) or not isinstance(data.get("entries"), dict): + return {"entries": {}, "unsupported_reported": False} + entries = {} + for key, raw_entry in data["entries"].items(): + if not isinstance(key, str) or not _HEX_DIGEST.fullmatch(key): + continue + entry = _clean_entry(raw_entry) + if entry is not None: + entries[key] = entry + return {"entries": entries, + "unsupported_reported": data.get("unsupported_reported") is True} + + +def _trim_state(state, now): + entries = state["entries"] + for key in list(entries): + touched = _number(entries[key].get("touched")) + if touched is None or now - touched > STATE_RETENTION_SECONDS: + del entries[key] + if len(entries) <= MAX_STATE_ENTRIES: + return + + def priority(item): + entry = item[1] + if entry["kind"] == "authfailure" and entry.get("delivery") == "pending": + rank = 0 + elif entry["kind"] == "authfailure": + rank = 1 + elif entry.get("delivery") == "pending": + rank = 2 + elif entry.get("delivery") == "sent": + rank = 3 + else: + rank = 4 + return rank, -entry.get("touched", 0) + + kept = sorted(entries.items(), key=priority)[:MAX_STATE_ENTRIES] + state["entries"] = dict(kept) + + +def _write_state(path, state): + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd, temporary = tempfile.mkstemp(prefix=".quota-alerts-", dir=path.parent) + try: + os.fchmod(fd, 0o600) + with os.fdopen(fd, "w", encoding="utf-8") as stream: + json.dump({"version": 1, **state}, stream, separators=(",", ":"), sort_keys=True) + stream.write("\n") + stream.flush() + os.fsync(stream.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +def _lock(path): + path.parent.mkdir(parents=True, exist_ok=True, mode=0o700) + fd = os.open(path, os.O_CREAT | os.O_WRONLY, 0o600) + os.fchmod(fd, 0o600) + lock = os.fdopen(fd, "w") + fcntl.flock(lock.fileno(), fcntl.LOCK_EX) + return lock + + +def _queue_event(entries, account_digest, kind, window, window_id, generation, + provider, now, remaining=None): + key = _event_id(account_digest, kind, window_id, generation) + existing = entries.get(key) + if existing is not None: + if existing.get("delivery") == "pending": + existing["provider"] = provider + existing["touched"] = now + if kind == "low": + existing["remaining"] = remaining + return key + entry = {"kind": kind, "delivery": "pending", "account": account_digest, + "provider": provider, "created": now, "touched": now} + if kind != "authfailure": + entry.update(window=window, window_id=window_id, generation=str(generation)) + if kind == "low": + entry["remaining"] = remaining + entries[key] = entry + return key + + +def _command(entry, executable): + if entry["kind"] == "low": + title = "OmaProxy quota alert" + body = f"{entry['provider']} · {entry['window']}: {entry['remaining']:g}% remaining." + elif entry["kind"] == "reset": + title = "OmaProxy quota refreshed" + body = f"{entry['provider']} · {entry['window']} allowance refreshed." + else: + title = "OmaProxy sign-in needed" + body = f"{entry['provider']} account needs sign-in." + return [executable, "--app-name=OmaProxy", "--", title, body] + + +def _desktop_environment(): + return {key: os.environ[key] for key in _ENV_ALLOWLIST if os.environ.get(key)} + + +def _send(runner, argv): + try: + result = runner(argv, check=False, timeout=5, shell=False, + env=_desktop_environment(), stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except Exception: + return False + return getattr(result, "returncode", None) == 0 + + +def process(snapshot, config_path, now=None, runner=None): + """Send de-duplicated alerts for a fresh normalized quota snapshot. + + `config_path` is the private configuration directory (for example + ``~/.config/omaproxy``). `runner`, when supplied, has the same call contract + as ``subprocess.run`` and is intended for deterministic tests. + """ + current = time.time() if now is None else _number(now) + if current is None or not isinstance(snapshot, dict): + return {"alert_count": 0, "error": "Invalid quota alert input."} + if not _is_fresh(snapshot.get("checked_at"), current): + return {"alert_count": 0} + accounts = snapshot.get("accounts") + if not isinstance(accounts, list): + return {"alert_count": 0} + try: + state_path, lock_path = _state_paths(config_path) + except (OSError, TypeError, ValueError): + return {"alert_count": 0, "error": "Private alert state path is unavailable."} + + sent_count = 0 + delivery_failed = False + unsupported_error = "" + lock = None + try: + lock = _lock(lock_path) + state = _load_state(state_path) + entries = state["entries"] + eligible = set() + + for account in accounts: + if not isinstance(account, dict): + continue + account_digest = _account_identity(account) + if account_digest is None or not _is_fresh(account.get("checked_at"), current): + continue + disabled = account.get("disabled") is True + provider = _provider_label(account) + status = _status(account.get("status")) + auth_id = _event_id(account_digest, "authfailure", "", "current") + + if disabled: + entries.pop(auth_id, None) + elif status in _AUTH_FAILURE_STATUSES: + _queue_event(entries, account_digest, "authfailure", "Account", "", "current", + provider, current) + eligible.add(auth_id) + elif status in _HEALTHY_STATUSES: + entries.pop(auth_id, None) + + if disabled or account.get("stale") or account.get("error"): + continue + if not _is_fresh(account.get("updated_at"), current): + continue + windows = account.get("windows") + if not isinstance(windows, list): + continue + + for window_data in windows: + if not isinstance(window_data, dict): + continue + raw_label = window_data.get("label") + label = _window_label(raw_label) + window_id = _window_id(raw_label) + remaining = _number(window_data.get("remaining_percent")) + if label is None or window_id is None or remaining is None or not 0 <= remaining <= 100: + continue + reset_at = _number(window_data.get("reset_at")) + generation = "unknown" if reset_at is None else str(int(reset_at // 60)) + observation_key = _observation_id(account_digest, window_id) + previous = entries.get(observation_key) + + if (previous and previous.get("kind") == "observation" + and previous.get("generation") != generation + and previous.get("reset_at") is not None + and reset_at is not None and reset_at > current - FUTURE_SKEW_SECONDS + and remaining > previous.get("remaining", 100)): + reset_id = _queue_event(entries, account_digest, "reset", label, window_id, + generation, provider, current) + if remaining > 0: + eligible.add(reset_id) + + if previous and previous.get("generation") != generation: + for pending_id, pending_event in list(entries.items()): + if (pending_event.get("account") == account_digest + and pending_event.get("window_id") == window_id + and pending_event.get("generation") != generation + and pending_event.get("delivery") == "pending"): + del entries[pending_id] + + entries[observation_key] = { + "kind": "observation", "account": account_digest, "window": label, + "window_id": window_id, + "generation": generation, "remaining": remaining, + "reset_at": reset_at, "touched": current, + } + + if remaining <= LOW_REMAINING_PERCENT: + low_id = _queue_event(entries, account_digest, "low", label, window_id, + generation, provider, current, remaining) + eligible.add(low_id) + else: + low_id = _event_id(account_digest, "low", window_id, generation) + pending = entries.get(low_id) + if pending is not None and pending.get("delivery") == "pending": + del entries[low_id] + + # A failed notify-send remains retryable while the fresh backend + # reading still supports the same reset generation. + reset_id = _event_id(account_digest, "reset", window_id, generation) + reset_pending = entries.get(reset_id) + if (reset_pending is not None and reset_pending.get("delivery") == "pending" + and remaining > 0): + eligible.add(reset_id) + + # Do not deliver persisted pending notifications from accounts/windows + # that this fresh snapshot did not validate. + pending_ids = [key for key in entries if key in eligible + and entries[key].get("delivery") == "pending"] + executable = shutil.which("notify-send") if pending_ids else None + if pending_ids and executable is None: + if not state["unsupported_reported"]: + unsupported_error = "Desktop notifications are unavailable (notify-send not found)." + state["unsupported_reported"] = True + elif executable is not None: + command_runner = runner or subprocess.run + for key in pending_ids: + entry = entries[key] + if _send(command_runner, _command(entry, executable)): + entry["delivery"] = "sent" + entry["touched"] = current + sent_count += 1 + else: + entry["touched"] = current + delivery_failed = True + + _trim_state(state, current) + _write_state(state_path, state) + except (OSError, ValueError, TypeError, json.JSONDecodeError, subprocess.SubprocessError): + return {"alert_count": sent_count, + "error": "Private alert state could not be read or written."} + finally: + if lock is not None: + lock.close() + + result = {"alert_count": sent_count} + if unsupported_error: + result["error"] = unsupported_error + elif delivery_failed: + result["error"] = "One or more desktop notifications could not be delivered." + return result diff --git a/scripts/routing.py b/scripts/routing.py new file mode 100644 index 0000000..d0936ec --- /dev/null +++ b/scripts/routing.py @@ -0,0 +1,158 @@ +"""Allowlisted routing controls; the caller owns its management mutation lock.""" +import re +import urllib.error + +V0 = "/v0/management/" +V8_ROUTING = "/v8/management/config/routing" +STRATEGIES = ("round-robin", "weighted-round-robin", "fill-first") +SCALARS = ("request-retry", "max-retry-credentials", "max-retry-interval") +AFFINITY = ("session-affinity", "session-affinity-ttl", "session-affinity-subagents") +COOLDOWN = ("disable-cooling", "save-cooldown-status") +BOUNDS = {"request-retry": (0, 10), "max-retry-credentials": (0, 100), + "max-retry-interval": (0, 300)} + + +def _optional_get(api, route): + try: + return api(route) + except urllib.error.HTTPError as error: + if error.code not in (404, 405): + raise + error.close() + return None + + +def supports_weights(backend_version): + """Only offer weighted routing for backend versions whose contract we checked.""" + match = re.fullmatch(r"v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?", str(backend_version or "")) + return bool(match and tuple(map(int, match.groups())) >= (7, 2, 154)) + + +def read_settings(api, backend_version=None): + """GET-only capability discovery. Never return the backend config document.""" + values, capabilities = {}, {} + strategy = _optional_get(api, V0 + "routing/strategy") + if isinstance(strategy, dict) and isinstance(strategy.get("strategy"), str): + values["strategy"] = strategy["strategy"] + capabilities["strategy"] = True + for field in SCALARS: + response = _optional_get(api, V0 + field) + if isinstance(response, dict) and type(response.get(field)) is int: + values[field] = response[field] + capabilities[field] = True + tree = _optional_get(api, V8_ROUTING) + v8 = isinstance(tree, dict) + if not v8: + # A missing subtree is possible on a fresh v8 config. This safe scalar + # identifies the v8 config API without reading credentials or full YAML. + v8 = _optional_get(api, "/v8/management/config/config-version") == 8 + if v8: + tree = tree if isinstance(tree, dict) else {} + capabilities.update({field: True for field in AFFINITY + COOLDOWN}) + for field in AFFINITY: + value = tree.get(field) + if field == "session-affinity-ttl": + values[field] = value if isinstance(value, str) and value else "1h" + else: + values[field] = value if type(value) is bool else field.endswith("subagents") + cooling = tree.get("cooldown", {}) + if isinstance(cooling, dict): + for field in COOLDOWN: + values[field] = cooling.get(field) if type(cooling.get(field)) is bool else False + retry = tree.get("retry", {}) + if isinstance(retry, dict): + for field in SCALARS: + if type(retry.get(field)) is int: + values[field] = retry[field] + capabilities[field] = True + if isinstance(tree.get("strategy"), str): + values["strategy"] = tree["strategy"] + capabilities["strategy"] = True + else: + config = _optional_get(api, V0 + "config") + if isinstance(config, dict): + routing = config.get("routing", {}) + if isinstance(routing, dict): + for field in AFFINITY: + value = routing.get(field) + if field == "session-affinity-ttl": + values[field] = value if isinstance(value, str) and value else "1h" + else: + values[field] = value if type(value) is bool else field.endswith("subagents") + for field in COOLDOWN: + values[field] = config.get(field) if type(config.get(field)) is bool else False + capabilities.update({field: False for field in AFFINITY + COOLDOWN}) + weighted = supports_weights(backend_version) or values.get("strategy") == "weighted-round-robin" + options = [item for item in STRATEGIES if weighted or item != "weighted-round-robin"] + return {"values": values, "capabilities": capabilities, "strategies": options, + "weights": weighted, "v8_config": v8, + "limitations": [] if v8 else ["Session affinity and cooldown flags require the v8 config PATCH API to edit safely."]} + + +def _duration(value): + if not isinstance(value, str) or not re.fullmatch(r"(?:\d+(?:\.\d+)?(?:h|m|s))+", value): + raise ValueError("Use a positive duration such as 30m, 1h, or 2h30m.") + seconds = sum(float(number) * {"h": 3600, "m": 60, "s": 1}[unit] + for number, unit in re.findall(r"(\d+(?:\.\d+)?)(h|m|s)", value)) + if not 1 <= seconds <= 86400: + raise ValueError("Session affinity TTL must be between 1 second and 24 hours.") + return value + + +def validate_changes(changes): + if not isinstance(changes, dict) or not changes: + raise ValueError("Provide at least one routing setting.") + unknown = set(changes) - {"strategy", *SCALARS, *AFFINITY, *COOLDOWN} + if unknown: + raise ValueError("Unsupported routing setting.") + for field, value in changes.items(): + if field == "strategy": + if value not in STRATEGIES: + raise ValueError("Choose a supported routing strategy.") + elif field in BOUNDS: + low, high = BOUNDS[field] + if type(value) is not int or not low <= value <= high: + raise ValueError(f"{field} must be an integer between {low} and {high}.") + elif field == "session-affinity-ttl": + _duration(value) + elif type(value) is not bool: + raise ValueError(f"{field} must be a boolean.") + return dict(changes) + + +def update_settings(api, changes, backend_version=None): + """Validate every requested change before writing; no whole-config replacement.""" + changes = validate_changes(changes) + snapshot = read_settings(api, backend_version) + for field, value in changes.items(): + if not snapshot["capabilities"].get(field): + raise ValueError(f"This backend cannot safely edit {field} through its management API.") + if field == "strategy" and value not in snapshot["strategies"]: + raise ValueError("Weighted routing is unavailable for this backend version.") + if snapshot["v8_config"]: + patch = {} + for field, value in changes.items(): + if field in SCALARS: + patch.setdefault("retry", {})[field] = value + elif field in COOLDOWN: + patch.setdefault("cooldown", {})[field] = value + else: + patch[field] = value + api(V8_ROUTING, "PATCH", patch) + else: + # v0 has only scalar writes. Report any partial failure explicitly; + # never retry or roll back an unverified backend response. + applied = [] + for field, value in changes.items(): + try: + route = "routing/strategy" if field == "strategy" else field + api(V0 + route, "PATCH", {"value": value}) + applied.append(field) + except Exception as error: + raise ValueError("Routing update failed; fields already applied: " + + (", ".join(applied) or "none") + ". Refresh settings before retrying.") from error + refreshed = read_settings(api, backend_version) + mismatches = [field for field, value in changes.items() if refreshed["values"].get(field) != value] + if mismatches: + raise ValueError("Backend did not confirm all routing changes. Refresh settings before retrying.") + return {"message": "Routing settings updated.", "routing_settings": refreshed} diff --git a/tests/fixtures/preview_bridge.py b/tests/fixtures/preview_bridge.py new file mode 100644 index 0000000..2f2f341 --- /dev/null +++ b/tests/fixtures/preview_bridge.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Offline display fixture. Never imports the real bridge or performs network I/O.""" +import json +import os +from pathlib import Path +import sys +import time + + +def main(): + root = Path(os.environ["OMAPROXY_PREVIEW_STATE"]) + state_file = root / "state.json" + state = json.loads(state_file.read_text()) if state_file.exists() else { + "running": True, "installed": "v6.9.20", "rollback": False, + "routing": "round-robin", "autostart": False, "mode": "local", "has_api_key": True, + } + args = sys.argv[1:] + command = args[0] if args else "status" + payload = json.loads(sys.stdin.readline()) if command in ("routing-save", "custom-save", "custom-add", "connection-save") else None + with (root / "actions.jsonl").open("a") as stream: + # Record command names and field names, never supplied values. + stream.write(json.dumps({"command": command, "argument_count": len(args) - 1, "payload_fields": sorted(payload or {}), "notification_requested": "--notify" in args}) + "\n") + providers = [{"id": "codex", "name": "OpenAI Codex"}, {"id": "claude", "name": "Claude"}, {"id": "gemini", "name": "Gemini"}] + accounts = [{"name": f"preview-{p['id']}.json", "auth_index": f"fixture-{p['id']}", "provider": p["id"], "email": f"preview-{p['id']}@example.invalid", "plan": "pro" if p["id"] == "codex" else "max", "disabled": False, "status": "ready", "success": 12, "failed": 1} for p in providers] + quotas = {"accounts": [{"name": a["name"], "provider": a["provider"], "email": a["email"], "plan": a["plan"], "available": True, "windows": [{"label": "Weekly", "remaining_percent": 72, "reset_at": time.time() + 86400, "used": None, "limit": None}, {"label": "5 hours", "remaining_percent": 43, "reset_at": time.time() + 3600, "used": None, "limit": None}]} for a in accounts]} + updates = {"installed_version": state["installed"], "latest_version": "v6.9.22", "reviewed_version": "v6.9.22", "update_supported": True, "update_available": state["installed"] != "v6.9.22", "rollback_available": state["rollback"], "error": ""} + values = {"strategy": state["routing"], "session-affinity": True, "session-affinity-ttl": "1h", "session-affinity-subagents": True, "request-retry": 2, "max-retry-credentials": 2, "max-retry-interval": 30, "disable-cooling": False, "save-cooldown-status": True} + values.update(state.get("routing_values", {})) + values["strategy"] = state["routing"] + routing = {"values": values, "weights": True, "capabilities": {k: True for k in values}, "strategies": ["round-robin", "fill-first", "weighted-round-robin"], "limitations": ["Offline preview: changes are stored only in temporary fixture state."]} + records = [{"label": "account-preview", "provider": "codex", "success": 12, "failed": 1}] + diagnostics = {"usage": {"availability": "available", "records": records, "retained": 1, "invalid": 1, "omitted": 2}, "accounts": {"availability": "available", "records": records, "retained": 1, "invalid": 2, "omitted": 3}, "queue": {"availability": "read_only_unavailable", "events": [], "error": "Automatic diagnostics do not consume the queue."}, "client_attribution": "unavailable", "limitations": ["Fixture counters describe backend attempts, not billing totals.", "Client identity and request routing are unavailable from aggregate counters."]} + custom = state.get("custom", [{"name": "preview-provider", "url": "https://provider.example.invalid/v1", "models": [{"name": "preview-upstream", "alias": "preview-model"}], "credential_count": 2, "weights": [1, 2]}]) + clients = state.get("client_keys", []) + if command == "status": + first_status = root / "initial-status-observed" + if not first_status.exists(): + first_status.touch() + time.sleep(float(os.environ.get("OMAPROXY_PREVIEW_STATUS_DELAY", "0"))) + mode = state.get("mode", "local") + remote = mode == "remote" + result = {"configured": True, "running": state["running"], "service": "connected" if remote else "active" if state["running"] else "inactive", "accounts": accounts, "models": ["preview-codex", "preview-claude"], "providers": providers, "autostart": state["autostart"], "endpoint": "https://proxy.example.invalid/v1" if remote else "http://127.0.0.1:0/v1", "version": state["installed"], "error": "", "quotas": quotas, "mode": mode, "connection_id": "remote-preview" if remote else "local", "base_url": "https://proxy.example.invalid" if remote else "", "remote_base_url": "https://proxy.example.invalid", "has_api_key": state.get("has_api_key", True)} + elif command == "quotas": + result = {"quotas": quotas} + if "--notify" in args: + result["alerts"] = {"error": "Preview alert delivery failed; no desktop notification was sent.", "sent": 0} + elif command == "auth-status": + result = {"auth": {"status": "none"}} + elif command == "preferences": + result = {"preferences": {"routing": state["routing"]}, "routing_settings": routing} + elif command in ("check-updates", "backend-update", "backend-rollback"): + if command != "check-updates": + state.update(installed="v6.9.22" if command == "backend-update" else "v6.9.20", rollback=command == "backend-update") + updates.update(installed_version=state["installed"], rollback_available=state["rollback"], update_available=command == "backend-rollback") + result = {"updates": updates, "message": "Preview backend " + command + " completed."} + elif command in ("routing-settings", "routing-save"): + if payload: + values.update(payload.get("changes", payload)) + state["routing"] = values["strategy"] + state["routing_values"] = values + result = {"routing_settings": routing, "message": "Preview routing settings loaded."} + elif command == "routing": + state["routing"] = args[1] + values["strategy"] = state["routing"] + result = {"preferences": {"routing": state["routing"]}, "routing_settings": routing, "message": "Preview routing changed."} + elif command in ("diagnostics", "capture-activity"): + if command == "capture-activity": + event = {"request_label": "request-preview", "account_label": "account-preview", "client_label": "client-0000000000000001", "model_label": "model-preview", "provider": "codex", "timestamp": "2026-10-03T18:00:00Z", "status_code": 200, "outcome": "success", "latency_ms": 280, "ttft_ms": 40, "tokens": {"input_tokens": 100, "output_tokens": 23, "cached_tokens": 0, "total_tokens": 123}} + if clients: + event["client_name"] = clients[0]["name"] + diagnostics["queue"] = {"availability": "available", "events": [event], "retained": 1, "invalid": 2, "omitted": 4, "capture_requested": True, "consumed": True} + diagnostics["client_attribution"] = "receipt_fields_only" + result = {"diagnostics": diagnostics} + elif command.startswith("custom-"): + if command in ("custom-save", "custom-add"): + name = payload["name"] + current = next((p for p in custom if p["name"] == name), None) + provider = {"name": name, "url": payload.get("url", current["url"] if current else ""), "models": payload.get("models", current["models"] if current else []), "credential_count": current["credential_count"] if current else 1, "weights": current["weights"] if current else [1]} + if "weight" in payload: + provider["weights"] = list(provider["weights"]) + provider["weights"][payload.get("credential_index", 0)] = payload["weight"] + custom = [p for p in custom if p["name"] != name] + [provider] + elif command == "custom-remove": + custom = [p for p in custom if p["name"] != args[1]] + state["custom"] = custom + for provider in custom: + provider["credentials"] = [{"index": i, "has_key": True, "weight": weight} for i, weight in enumerate(provider["weights"])] + result = {"custom_providers": custom, "provider_weights_supported": True, "message": "Preview provider action completed."} + if command in ("custom-save", "custom-add"): + result["custom_provider"] = next(p for p in custom if p["name"] == payload["name"]) + elif command in ("client-keys", "client-create", "client-copy", "client-revoke"): + if command == "client-create" and not any(row["name"] == args[1] for row in clients): + clients = clients + [{"name": args[1], "key_label": "client-0000000000000001", "active": True}] + elif command == "client-revoke": + clients = [row for row in clients if row["name"] != args[1]] + state["client_keys"] = clients + result = {"client_keys": clients, "message": "Preview client key " + command + " completed."} + if command == "client-copy": + result.update(copied=True, message="Preview client key copy recorded; clipboard unchanged.") + elif command == "connection-save": + state.update(mode="remote", has_api_key=False if payload.get("clear_api_key") else bool(payload.get("api_key")) or state.get("has_api_key", True)) + result = {"connection_changed": True, "connection_id": "remote-preview", "mode": "remote", "base_url": "https://proxy.example.invalid", "has_api_key": state["has_api_key"], "message": "Preview remote connection saved."} + elif command == "connection-local": + state["mode"] = "local" + result = {"connection_changed": True, "connection_id": "local", "mode": "local", "remote_base_url": "https://proxy.example.invalid", "message": "Preview local connection selected."} + elif command in ("start", "stop", "restart"): + state["running"] = command != "stop" + result = {"message": "Preview proxy state changed."} + elif command == "autostart": + state["autostart"] = args[1] == "on" + result = {"message": "Preview login setting changed."} + elif command == "logs-view": + result = {"logs": "Offline preview. No backend was started."} + elif command == "copy": + result = {"message": "Preview copy action recorded; clipboard unchanged."} + else: + result = {"message": "Preview action recorded: " + command} + if command in ("backend-update", "backend-rollback", "routing-save", "routing", "start", "stop", "restart", "autostart", "custom-save", "custom-add", "custom-remove", "connection-save", "connection-local", "client-create", "client-revoke"): + temporary = root / ("state-" + str(os.getpid()) + ".json") + temporary.write_text(json.dumps(state)) + temporary.replace(state_file) + print(json.dumps(result)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_client_keys.py b/tests/test_client_keys.py new file mode 100644 index 0000000..a2e36bd --- /dev/null +++ b/tests/test_client_keys.py @@ -0,0 +1,255 @@ +"""Named key operations preserve unrelated keys and never return raw secrets.""" +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +from types import SimpleNamespace +import unittest +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import client_keys +import diagnostics + + +class Management: + def __init__(self): + self.keys = ["fake-primary-secret", "fake-unmanaged-secret"] + self.calls = [] + self.fail_readback = False + self.fail_response = False + self.no_mutation = False + + def __call__(self, route, method="GET", body=None): + self.calls.append((route, method, body)) + if method == "GET": + if self.fail_readback: + raise RuntimeError("backend fake-primary-secret") + return {"api-keys": self.keys.copy()} + if not self.no_mutation: + if method == "PATCH": + if body["old"] not in self.keys: + self.keys.append(body["new"]) + elif method == "DELETE": + value = parse_qs(urlsplit(route).query)["value"][0].strip() + self.keys = [key for key in self.keys if key.strip() != value] + else: + raise AssertionError("Unexpected full-array write") + if self.fail_response: + raise RuntimeError("mutation secret " + repr(self.keys)) + return {"status": "ok"} + + +class ClientKeyTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.path = Path(self.temp.name) / "private" / "client-keys.json" + self.backend = Management() + self.salt = "fake-private-management-salt" + self.primary = self.backend.keys[0] + + def create(self, name="codex-cli"): + return client_keys.create_key(self.backend, self.path, self.salt, name, self.primary) + + def seed(self, key, name="codex-cli"): + client_keys._write(self.path, [{"name": name, "key_label": client_keys._label(key, self.salt), "state": "active"}]) + + def test_list_is_get_only_and_does_not_assign_unmanaged_keys(self): + self.assertEqual(client_keys.list_keys(self.backend, self.path, self.salt), {"client_keys": []}) + self.assertEqual(self.backend.calls, [(client_keys.ROUTE, "GET", None)]) + self.assertFalse(self.path.exists()) + + def test_create_hash_only_private_registry_and_diagnostics_label(self): + result = self.create() + key = self.backend.keys[-1] + row = result["client_key"] + self.assertEqual(row["key_label"], diagnostics._label("client", key, self.salt.encode())) + self.assertTrue(row["active"]) + self.assertEqual(self.path.stat().st_mode & 0o777, 0o600) + for secret in self.backend.keys: + self.assertNotIn(secret, str(result)) + self.assertNotIn(secret, self.path.read_text()) + self.assertEqual(self.backend.keys[:2], [self.primary, "fake-unmanaged-secret"]) + writes = [(route, method, body) for route, method, body in self.backend.calls if method != "GET"] + self.assertEqual(writes, [(client_keys.ROUTE, "PATCH", {"old": key, "new": key})]) + + def test_create_same_name_is_idempotent(self): + first = self.create() + second = self.create() + self.assertEqual(first, second) + self.assertEqual(len(self.backend.keys), 3) + self.assertEqual(sum(method == "PATCH" for _, method, _ in self.backend.calls), 1) + + def test_failed_response_applied_mutation_recovers_by_readback(self): + self.backend.fail_response = True + self.assertTrue(self.create()["client_key"]["active"]) + + def test_lost_readback_keeps_pending_and_retry_does_not_duplicate(self): + def api(route, method="GET", body=None): + result = self.backend(route, method, body) + if method == "PATCH": + self.backend.fail_readback = True + return result + with self.assertRaises(ValueError) as caught: + client_keys.create_key(api, self.path, self.salt, "t3-code") + self.assertNotIn(self.primary, str(caught.exception)) + self.assertEqual(json.loads(self.path.read_text())["client_keys"][0]["state"], "pending") + self.backend.fail_readback = False + self.assertTrue(self.create("t3-code")["client_key"]["active"]) + self.assertEqual(len(self.backend.keys), 3) + + def test_unapplied_creation_retry_does_not_generate_second_key(self): + self.backend.no_mutation = True + for _ in range(2): + with self.assertRaises(ValueError): + self.create() + self.assertEqual(sum(method == "PATCH" for _, method, _ in self.backend.calls), 1) + client_keys.revoke_key(self.backend, self.path, self.salt, "codex-cli", self.primary) + self.backend.no_mutation = False + self.assertTrue(self.create()["client_key"]["active"]) + + def test_revoke_preserves_primary_unmanaged_and_other_named_key(self): + self.create("codex-cli") + self.create("opencode") + keep = self.backend.keys[-1] + result = client_keys.revoke_key(self.backend, self.path, self.salt, "codex-cli", self.primary) + self.assertTrue(result["revoked"]) + self.assertEqual(self.backend.keys, [self.primary, "fake-unmanaged-secret", keep]) + self.assertEqual([row["name"] for row in result["client_keys"]], ["opencode"]) + delete = next(call for call in self.backend.calls if call[1] == "DELETE") + self.assertIn("?value=", delete[0]) + self.assertIsNone(delete[2]) + + def test_revoke_requires_primary_protection_before_backend_call(self): + self.create() + self.backend.calls.clear() + with self.assertRaisesRegex(ValueError, "protection"): + client_keys.revoke_key(self.backend, self.path, self.salt, "codex-cli") + self.assertFalse(self.backend.calls) + + def test_primary_revoke_and_trimmed_alias_are_protected(self): + for key in (self.primary, " " + self.primary + " "): + with self.subTest(key=key): + self.seed(key) + if key not in self.backend.keys: + self.backend.keys.append(key) + with self.assertRaisesRegex(ValueError, "protected"): + client_keys.revoke_key(self.backend, self.path, self.salt, "codex-cli", self.primary) + self.assertTrue(all(method == "GET" for _, method, _ in self.backend.calls)) + + def test_revoke_ambiguous_trimmed_value_cannot_delete_unrelated_key(self): + self.create() + self.backend.keys.append(" " + self.backend.keys[-1] + " ") + with self.assertRaisesRegex(ValueError, "ambiguous"): + client_keys.revoke_key(self.backend, self.path, self.salt, "codex-cli", self.primary) + self.assertFalse(any(method == "DELETE" for _, method, _ in self.backend.calls)) + + def test_failed_revoke_readback_keeps_registry_for_retry(self): + self.create() + self.backend.no_mutation = True + with self.assertRaisesRegex(ValueError, "not confirmed"): + client_keys.revoke_key(self.backend, self.path, self.salt, "codex-cli", self.primary) + self.assertEqual(len(json.loads(self.path.read_text())["client_keys"]), 1) + self.backend.no_mutation = False + client_keys.revoke_key(self.backend, self.path, self.salt, "codex-cli", self.primary) + self.assertEqual(len(self.backend.keys), 2) + + def test_copy_passes_secret_only_to_stdin(self): + self.create() + seen = [] + def runner(args, **kwargs): + seen.append((args, kwargs)) + return SimpleNamespace(returncode=0) + result = client_keys.copy_key(self.backend, self.path, self.salt, "codex-cli", runner=runner) + args, kwargs = seen[0] + self.assertEqual(args, ["wl-copy"]) + self.assertEqual(kwargs["input"], self.backend.keys[-1]) + self.assertEqual(kwargs["stdout"], subprocess.DEVNULL) + self.assertEqual(kwargs["stderr"], subprocess.DEVNULL) + self.assertNotIn(self.backend.keys[-1], str(result)) + + def test_copy_failure_redacts_exception_and_stderr(self): + self.create() + def runner(*args, **kwargs): + raise RuntimeError("sensitive " + kwargs["input"]) + with self.assertRaisesRegex(ValueError, "could not be copied") as caught: + client_keys.copy_key(self.backend, self.path, self.salt, "codex-cli", runner=runner) + self.assertNotIn(self.backend.keys[-1], str(caught.exception)) + self.assertTrue(caught.exception.__suppress_context__) + + def test_inactive_named_key_cannot_be_copied(self): + self.seed("fake-removed-key") + self.assertFalse(client_keys.list_keys(self.backend, self.path, self.salt)["client_keys"][0]["active"]) + with self.assertRaisesRegex(ValueError, "inactive"): + client_keys.copy_key(self.backend, self.path, self.salt, "codex-cli") + + def test_invalid_names_and_salt_fail_before_backend_call(self): + for name in ("", "../secrets", "a\nsecret", "a" * 61, " spaced", "🙂"): + with self.assertRaises(ValueError): + self.create(name) + with self.assertRaises(ValueError): + client_keys.list_keys(self.backend, self.path, b"") + self.assertFalse(self.backend.calls) + + def test_corrupt_registry_is_preserved_and_never_echoed(self): + self.path.parent.mkdir() + self.path.write_text('{"secret":"fake-registry-secret"}') + with self.assertRaises(ValueError) as caught: + self.create() + self.assertNotIn("fake-registry-secret", str(caught.exception)) + self.assertFalse(self.backend.calls) + self.assertIn("fake-registry-secret", self.path.read_text()) + + def test_registry_symlink_is_not_followed(self): + target = Path(self.temp.name) / "target.json" + target.write_text("keep") + self.path.parent.mkdir() + self.path.symlink_to(target) + with self.assertRaises(ValueError): + self.create() + self.assertEqual(target.read_text(), "keep") + + def test_invalid_or_oversized_backend_lists_prevent_mutation(self): + for payload in ({"api-keys": None}, {"api-keys": ["valid", None]}, {"api-keys": ["valid"] * 201}, {}, {"api-keys": [""]}): + calls = [] + def api(route, method="GET", body=None): + calls.append(method) + return payload + with self.assertRaises(ValueError): + client_keys.create_key(api, self.path, self.salt, "agy") + self.assertEqual(calls, ["GET"]) + self.assertFalse(self.path.exists()) + + def test_registry_write_failure_prevents_backend_mutation(self): + with patch.object(client_keys, "_write", side_effect=ValueError("safe")): + with self.assertRaises(ValueError): + self.create() + self.assertTrue(all(method == "GET" for _, method, _ in self.backend.calls)) + + +@unittest.skipUnless(os.environ.get("OMAPROXY_TEST_BINARY"), "set OMAPROXY_TEST_BINARY for isolated backend tests") +class RealClientKeyTests(unittest.TestCase): + def test_real_append_revoke_preserves_unrelated_keys_and_config(self): + from test_routing import isolated_backend + with tempfile.TemporaryDirectory() as temp, isolated_backend() as (api, config): + path = Path(temp) / "client-keys.json" + primary = "fake-client-key" + created = client_keys.create_key(api, path, "fake-management-key", "kiro-cli", primary) + self.assertTrue(created["client_key"]["active"]) + keys = api(client_keys.ROUTE)["api-keys"] + self.assertIn(primary, keys) + self.assertEqual(len(keys), 2) + saved = config.read_text() + for value in ("fake-upstream-key", "fixture comment must survive", "retry comment", "unrelated comment"): + self.assertIn(value, saved) + client_keys.revoke_key(api, path, "fake-management-key", "kiro-cli", primary) + self.assertEqual(api(client_keys.ROUTE)["api-keys"], [primary]) + self.assertEqual(client_keys.list_keys(api, path, "fake-management-key"), {"client_keys": []}) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_controls_bridge.py b/tests/test_controls_bridge.py new file mode 100644 index 0000000..8fa0eeb --- /dev/null +++ b/tests/test_controls_bridge.py @@ -0,0 +1,111 @@ +import contextlib +import importlib.util +import io +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import Mock, patch + +SCRIPTS = Path(__file__).parents[1] / "scripts" +sys.path.insert(0, str(SCRIPTS)) +spec = importlib.util.spec_from_file_location("controls_bridge", SCRIPTS / "omaproxy.py") +bridge = importlib.util.module_from_spec(spec) +spec.loader.exec_module(bridge) + +import client_keys +import quota_alerts + + +class ControlsBridgeTests(unittest.TestCase): + def test_pending_update_blocks_mutations_before_network_io(self): + with tempfile.TemporaryDirectory() as temporary, \ + patch.object(bridge, "DATA", Path(temporary)), \ + patch.object(bridge, "settings", return_value={"port": 18317, "management_key": "fake"}), \ + patch.object(bridge, "request") as request: + (Path(temporary) / "backend-pending").mkdir() + with self.assertRaisesRegex(ValueError, "needs recovery"): + bridge.api("api-keys", "DELETE") + request.assert_not_called() + + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="omaproxy-controls-") + self.addCleanup(self.temp.cleanup) + self.config = Path(self.temp.name) / "config/omaproxy" + self.config_patch = patch.object(bridge, "CONFIG", self.config) + self.config_patch.start() + self.addCleanup(self.config_patch.stop) + + def configure(self): + settings = { + "port": 18317, + "management_key": "management-test-secret", + "api_key": "protected-primary-test-key", + "providers": [], + "version": "test", + "binary": "/fake/backend", + } + bridge.private_write(self.config / "settings.json", json.dumps(settings)) + return settings + + def test_quotas_cli_only_processes_alerts_after_opt_in_and_cached_write(self): + self.configure() + api = Mock(return_value={"files": []}) + output = io.StringIO() + + def process_after_write(snapshot, config_path): + cached = json.loads((self.config / "quotas.json").read_text()) + self.assertEqual(snapshot, cached) + self.assertEqual(config_path, self.config) + return {"alert_count": 0} + + with patch.object(bridge, "api", api), \ + patch.object(quota_alerts, "process", side_effect=process_after_write) as process, \ + patch.object(sys, "argv", ["omaproxy.py", "quotas"]), \ + contextlib.redirect_stdout(output): + bridge.main() + + default_result = json.loads(output.getvalue()) + self.assertIn("quotas", default_result) + self.assertNotIn("alerts", default_result) + process.assert_not_called() + self.assertFalse((self.config / "quota-alerts.json").exists()) + + output = io.StringIO() + with patch.object(bridge, "api", api), \ + patch.object(quota_alerts, "process", side_effect=process_after_write) as process, \ + patch.object(sys, "argv", ["omaproxy.py", "quotas", "--notify"]), \ + contextlib.redirect_stdout(output): + bridge.main() + + opted_in_result = json.loads(output.getvalue()) + self.assertEqual(opted_in_result["alerts"], {"alert_count": 0}) + process.assert_called_once() + api.assert_called_with("auth-files") + + def test_client_revoke_passes_primary_key_from_private_settings(self): + settings = self.configure() + settings_path = self.config / "settings.json" + settings_before = settings_path.read_text() + revoke = Mock(return_value={"revoked": True, "name": "downstream"}) + output = io.StringIO() + + with patch.object(client_keys, "revoke_key", revoke), \ + patch.object(sys, "argv", ["omaproxy.py", "client-revoke", "downstream"]), \ + contextlib.redirect_stdout(output): + bridge.main() + + self.assertEqual(revoke.call_count, 1) + self.assertIs(revoke.call_args.args[0], bridge.api) + self.assertEqual(revoke.call_args.args[1], self.config / "client-keys.json") + self.assertEqual(revoke.call_args.args[2], settings["management_key"]) + self.assertEqual(revoke.call_args.args[3], "downstream") + self.assertEqual(revoke.call_args.kwargs["primary_key"], settings["api_key"]) + self.assertEqual(settings_path.read_text(), settings_before) + self.assertNotIn(settings["api_key"], output.getvalue()) + self.assertNotIn(settings["management_key"], output.getvalue()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py new file mode 100644 index 0000000..9097884 --- /dev/null +++ b/tests/test_diagnostics.py @@ -0,0 +1,227 @@ +import json +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock +from urllib.error import HTTPError + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import diagnostics + + +def counter(success=2, failed=1): + return {"success": success, "failed": failed, + "recent_requests": [{"time": "09:00-09:10", "success": success, "failed": failed}]} + + +def missing(): + return HTTPError("http://private.invalid/secret", 404, "private key", {}, None) + + +class DiagnosticsTests(unittest.TestCase): + def test_v8_uses_exact_schema_and_upstream_scope(self): + raw = {"codex": {"https://user:private@example.invalid|sk-private": counter()}} + api = Mock(return_value=raw) + result = diagnostics.snapshot(api, accounts=[], salt="private-salt") + self.assertEqual(api.call_args.args[0], "/v8/management/observability/usage/api-keys") + self.assertEqual(api.call_args.kwargs, {"timeout": 2}) + self.assertEqual(result["usage"]["availability"], "available") + record = result["usage"]["records"][0] + self.assertEqual(record["success"], 2) + self.assertTrue(record["label"].startswith("upstream-key-")) + self.assertEqual(result["client_attribution"], "unavailable") + self.assertEqual(result["queue"]["events"], []) + self.assertNotIn("private", json.dumps(result)) + self.assertIn("exclude OAuth", " ".join(result["limitations"])) + + def test_v7_fallback_and_oauth_account_counters(self): + api = Mock(side_effect=[missing(), {}, missing(), {"files": [dict(counter(), + provider="codex", auth_index="auth-private", id="email@example.invalid.json")]}]) + result = diagnostics.snapshot(api, salt=b"installation-salt") + self.assertEqual([call.args[0] for call in api.call_args_list], [ + "/v8/management/observability/usage/api-keys", "/v0/management/api-key-usage", + "/v8/management/credentials", "/v0/management/auth-files"]) + self.assertEqual(result["accounts"]["records"][0]["success"], 2) + self.assertEqual(result["usage"]["records"], []) + self.assertNotIn("email", json.dumps(result)) + self.assertNotIn("auth-private", json.dumps(result)) + + def test_never_probes_nonexistent_usage_or_consumes_queue_by_default(self): + api = Mock(side_effect=[missing(), missing()]) + result = diagnostics.snapshot(api, accounts=[]) + self.assertEqual(result["usage"]["availability"], "unsupported") + self.assertEqual(api.call_count, 2) + for call in api.call_args_list: + self.assertNotIn("queue", call.args[0]) + self.assertNotEqual(call.args[0], "/v0/management/usage") + + def test_auth_failure_does_not_fallback_and_never_echoes_exception(self): + for code in (401, 403, 429, 500): + api = Mock(side_effect=HTTPError("http://email@example.invalid/sk-secret", code, + "Bearer sk-secret", {}, None)) + result = diagnostics.snapshot(api, accounts=[]) + self.assertEqual(api.call_count, 1) + self.assertEqual(result["usage"]["availability"], "unavailable") + self.assertNotIn("secret", json.dumps(result)) + self.assertNotIn("email", json.dumps(result)) + result = diagnostics.snapshot(Mock(side_effect=RuntimeError("sk-secret")), accounts=[]) + self.assertNotIn("sk-secret", json.dumps(result)) + + def test_empty_is_valid_but_invalid_is_unknown(self): + valid = diagnostics.snapshot(Mock(return_value={}), accounts=[]) + self.assertEqual(valid["usage"]["availability"], "available") + self.assertEqual(valid["usage"]["retained"], 0) + for raw in (None, [], {"error": "private"}, {"codex": {"key": {}}}): + result = diagnostics.snapshot(Mock(return_value=raw), accounts=[]) + self.assertEqual(result["usage"]["availability"], "unknown") + self.assertNotIn("private", json.dumps(result)) + + def test_malformed_counts_are_unknown_not_zero(self): + for value in (None, True, -1, float("nan"), "2", 2**70): + result = diagnostics.snapshot(Mock(return_value={"codex": {"key": counter(value)}}), accounts=[]) + self.assertEqual(result["usage"]["records"], []) + self.assertEqual(result["usage"]["invalid"], 1) + + def test_labels_are_stable_with_private_salt_and_change_across_installations(self): + api = Mock(return_value={"codex": {"key": counter()}}) + one = diagnostics.snapshot(api, accounts=[], salt="salt-one") + two = diagnostics.snapshot(api, accounts=[], salt="salt-one") + other = diagnostics.snapshot(api, accounts=[], salt="salt-two") + self.assertEqual(one["usage"]["records"], two["usage"]["records"]) + self.assertNotEqual(one["usage"]["records"][0]["label"], other["usage"]["records"][0]["label"]) + + def test_bounds_records_and_rejects_free_text_buckets_providers(self): + rows = {"private-" + str(i): counter() for i in range(200)} + result = diagnostics.snapshot(Mock(return_value={"sk-secret@example.invalid": rows}), accounts=[]) + self.assertEqual(result["usage"]["retained"], 128) + self.assertEqual(result["usage"]["omitted"], 72) + self.assertNotIn("private", json.dumps(result)) + self.assertNotIn("secret", json.dumps(result)) + item = counter() + item["recent_requests"] = [{"time": "sk-secret", "success": 0, "failed": 0}] * 100 + result = diagnostics.snapshot(Mock(return_value={"codex": {"key": item}}), accounts=[]) + self.assertEqual(len(result["usage"]["records"][0]["recent_requests"]), 20) + self.assertNotIn("sk-secret", json.dumps(result)) + + def test_explicit_queue_capture_bounded_and_attribution_only_receipt_fields(self): + raw = {"api_key": "sk-client", "auth_index": "email@example.invalid.json", + "request_id": "secret-request", "execution_id": "secret-execution", "model": "secret-model", + "provider": "codex", "timestamp": "2026-10-03T12:10:00Z", "failed": True, + "latency_ms": 110, "ttft_ms": 30, "tokens": {"input_tokens": 4, "output_tokens": 5}, + "fail": {"status_code": 429, "body": "private prompt"}, "prompt": "private prompt", + "response_headers": {"Authorization": "Bearer sk-token"}, "tool": "private tools"} + api = Mock(side_effect=[{}, [raw]]) + result = diagnostics.snapshot(api, accounts=[], salt="private-salt", consume_queue=True) + self.assertEqual(api.call_args.args[0], "/v8/management/observability/usage/queue?count=50") + self.assertTrue(result["queue"]["consumed"]) + event = result["queue"]["events"][0] + self.assertEqual(event["outcome"], "failed") + self.assertEqual(event["status_code"], 429) + self.assertEqual(event["latency_ms"], 110) + self.assertEqual(event["tokens"], {"input_tokens": 4, "output_tokens": 5}) + self.assertIn("client_label", event) + self.assertIn("account_label", event) + self.assertNotIn("retries", event) + for secret in ("private", "sk-client", "email@", "secret-request", "secret-model", "Bearer", "Authorization"): + self.assertNotIn(secret, json.dumps(result)) + self.assertEqual(len(diagnostics.sanitize_events([raw] * 100)), 50) + unidentified = diagnostics.sanitize_events([{"source": "email.json", "failed": False}])[0] + self.assertNotIn("client_label", unidentified) + self.assertNotIn("account_label", unidentified) + self.assertNotIn("latency_ms", unidentified) + self.assertNotIn("tokens", unidentified) + + def test_unrecognized_queue_schema_and_v7_capture_fallback(self): + api = Mock(side_effect=[{}, missing(), [{"failed": False}]]) + result = diagnostics.snapshot(api, accounts=[], consume_queue=True) + self.assertEqual(result["queue"]["source"], "/v0/management/usage-queue?count=50") + self.assertEqual(result["queue"]["events"][0]["outcome"], "success") + api = Mock(side_effect=[{}, {"api_key": "private"}]) + result = diagnostics.snapshot(api, accounts=[], consume_queue=True) + self.assertEqual(result["queue"]["availability"], "unknown") + self.assertNotIn("private", json.dumps(result)) + + def test_capture_counts_invalid_and_omitted_in_distinct_populations(self): + # The five oldest receipts are beyond the display limit. Only the newest + # fifty records are inspected, including three invalid records. + discarded = [{"api_key": "private-old-client", "failed": False}] * 5 + inspected = [None, "private-body", {"prompt": "private-prompt"}] + inspected += [{"failed": False, "latency_ms": number} for number in range(47)] + api = Mock(side_effect=[{}, discarded + inspected]) + result = diagnostics.snapshot(api, accounts=[], consume_queue=True) + queue = result["queue"] + self.assertEqual(queue["availability"], "available") + self.assertTrue(queue["capture_requested"]) + self.assertTrue(queue["consumed"]) + self.assertEqual(queue["retained"], 47) + self.assertEqual(queue["invalid"], 3) + self.assertEqual(queue["omitted"], 5) + self.assertEqual(queue["retained"] + queue["invalid"] + queue["omitted"], 55) + self.assertEqual([event["latency_ms"] for event in queue["events"]], list(range(47))) + self.assertEqual(result["client_attribution"], "unavailable") + self.assertNotIn("private", json.dumps(result)) + + def test_all_invalid_capture_is_consumed_but_schema_unknown(self): + records = [None, {}, {"prompt": "private-prompt"}, {"source": "private-auth.json"}, + {"failed": "false", "latency_ms": "10", "api_key": None}] + api = Mock(side_effect=[{}, records]) + result = diagnostics.snapshot(api, accounts=[], consume_queue=True) + queue = result["queue"] + self.assertEqual(queue["availability"], "unknown") + self.assertTrue(queue["consumed"]) + self.assertTrue(queue["capture_requested"]) + self.assertEqual(queue["events"], []) + self.assertEqual(queue["retained"], 0) + self.assertEqual(queue["invalid"], 5) + self.assertEqual(queue["omitted"], 0) + self.assertEqual(result["client_attribution"], "unavailable") + self.assertIn("schema", queue["error"]) + self.assertNotIn("private", json.dumps(result)) + + def test_empty_capture_is_available_without_attribution(self): + api = Mock(side_effect=[{}, []]) + result = diagnostics.snapshot(api, accounts=[], consume_queue=True) + queue = result["queue"] + self.assertEqual(queue["availability"], "available") + self.assertTrue(queue["consumed"]) + self.assertEqual(queue["events"], []) + self.assertEqual((queue["retained"], queue["invalid"], queue["omitted"]), (0, 0, 0)) + self.assertEqual(result["client_attribution"], "unavailable") + + def test_receipt_presence_alone_does_not_establish_client_attribution(self): + for key in (None, "", 123, True, "x" * 16385): + with self.subTest(api_key_type=type(key).__name__): + receipt = {"failed": False, "api_key": key, "auth_index": "private-account", + "request_id": "private-request", "source": "private-auth.json"} + api = Mock(side_effect=[{}, [receipt]]) + result = diagnostics.snapshot(api, accounts=[], consume_queue=True) + self.assertEqual(result["queue"]["availability"], "available") + self.assertEqual(result["queue"]["retained"], 1) + event = result["queue"]["events"][0] + self.assertIn("account_label", event) + self.assertIn("request_label", event) + self.assertNotIn("client_label", event) + self.assertEqual(result["client_attribution"], "unavailable") + self.assertNotIn("private", json.dumps(result)) + api = Mock(side_effect=[{}, [{"failed": False}, {"failed": True, "api_key": "private-client"}]]) + result = diagnostics.snapshot(api, accounts=[], consume_queue=True) + self.assertEqual(result["client_attribution"], "receipt_fields_only") + self.assertNotIn("client_label", result["queue"]["events"][0]) + self.assertIn("client_label", result["queue"]["events"][1]) + self.assertNotIn("private-client", json.dumps(result)) + + def test_denied_capture_was_requested_but_not_consumed(self): + denied = HTTPError("http://private.invalid/private-key", 403, "private-error", {}, None) + api = Mock(side_effect=[{}, denied]) + result = diagnostics.snapshot(api, accounts=[], consume_queue=True) + self.assertEqual(api.call_count, 2) + self.assertEqual(result["queue"]["availability"], "unavailable") + self.assertTrue(result["queue"]["capture_requested"]) + self.assertFalse(result["queue"]["consumed"]) + self.assertEqual(result["queue"]["events"], []) + self.assertEqual(result["client_attribution"], "unavailable") + self.assertNotIn("private", json.dumps(result)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_providers.py b/tests/test_providers.py new file mode 100644 index 0000000..bcd1ff0 --- /dev/null +++ b/tests/test_providers.py @@ -0,0 +1,234 @@ +"""Provider mutations target exact definitions and never expose secrets.""" +import copy +import io +import json +import os +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock +import urllib.error +import urllib.parse + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import providers + + +class Management: + def __init__(self): + self.calls = [] + self.entries = [{"name": "first", "base-url": "https://example.test/v1", + "api-key-entries": [{"api-key": "fake-secret", "weight": 4, "proxy-url": "http://proxy.test"}], + "models": [{"name": "model-a", "alias": "alias-a", "thinking": {"levels": ["low"]}}], + "headers": {"private-header": "fake-header-secret"}, "disabled": True}, + {"name": "second", "base-url": "https://other.test/v1", + "api-key-entries": [{"api-key": "other-secret"}], "models": []}] + + def __call__(self, route, method="GET", body=None): + self.calls.append((route, method, copy.deepcopy(body))) + if method == "GET": + return {"openai-compatibility": copy.deepcopy(self.entries)} + if method == "PATCH": + target = next(entry for entry in self.entries if entry["name"] == body["name"]) + target.update(copy.deepcopy(body["value"])) + elif method == "PUT": + self.entries = copy.deepcopy(body) + elif method == "DELETE": + name = urllib.parse.parse_qs(urllib.parse.urlsplit(route).query)["name"][0] + self.entries = [entry for entry in self.entries if entry["name"] != name] + else: + raise AssertionError(method) + return {"status": "ok"} + + +class ProviderTests(unittest.TestCase): + def test_listing_is_allowlisted(self): + backend = Management() + result = providers.list_providers(backend) + rendered = json.dumps(result) + self.assertNotIn("secret", rendered) + self.assertNotIn("headers", rendered) + self.assertNotIn("proxy-url", rendered) + self.assertEqual(result["custom_providers"][0]["credentials"][0]["weight"], 4) + self.assertEqual(len(backend.calls), 1) + + def test_bad_config_url_is_hidden(self): + backend = Management() + backend.entries[0]["base-url"] = "https://user:fake-secret@example.test/v1" + self.assertEqual(providers.list_providers(backend)["custom_providers"][0]["url"], "") + + def test_edit_uses_patch_and_blank_key_preserves_all_secrets(self): + backend = Management() + before = copy.deepcopy(backend.entries) + result = providers.upsert_provider(backend, {"name": "first", "url": "https://new.test/v1", "key": ""}) + write = next(call for call in backend.calls if call[1] == "PATCH") + self.assertEqual(write[2], {"name": "first", "value": {"base-url": "https://new.test/v1"}}) + self.assertEqual(backend.entries[0]["api-key-entries"], before[0]["api-key-entries"]) + self.assertEqual(backend.entries[1], before[1]) + self.assertNotIn("fake-secret", str(result)) + + def test_edit_models_preserves_selected_model_capabilities(self): + backend = Management() + providers.upsert_provider(backend, {"name": "first", "models": [{"name": "model-a", "alias": "alias-a"}, "model-b"]}) + self.assertEqual(backend.entries[0]["models"][0]["thinking"], {"levels": ["low"]}) + + def test_key_replacement_preserves_weight_proxy_and_unrelated_provider(self): + backend = Management() + providers.upsert_provider(backend, {"name": "first", "key": "new-fake-secret"}) + self.assertEqual(backend.entries[0]["api-key-entries"], + [{"api-key": "new-fake-secret", "weight": 4, "proxy-url": "http://proxy.test"}]) + self.assertEqual(backend.entries[1]["api-key-entries"][0]["api-key"], "other-secret") + + def test_weight_zero_and_reset_preserve_key(self): + backend = Management() + providers.upsert_provider(backend, {"name": "first", "weight": 0}, weights_supported=True) + self.assertEqual(backend.entries[0]["api-key-entries"][0]["weight"], 0) + providers.upsert_provider(backend, {"name": "first", "weight": None}, weights_supported=True) + self.assertNotIn("weight", backend.entries[0]["api-key-entries"][0]) + self.assertEqual(backend.entries[0]["api-key-entries"][0]["api-key"], "fake-secret") + + def test_multi_key_edit_requires_explicit_index(self): + backend = Management() + backend.entries[0]["api-key-entries"].append({"api-key": "fake-second-key"}) + with self.assertRaisesRegex(ValueError, "which"): + providers.upsert_provider(backend, {"name": "first", "key": "new-key"}) + providers.upsert_provider(backend, {"name": "first", "key": "new-key", "credential_index": 1}) + self.assertEqual(backend.entries[0]["api-key-entries"][0]["api-key"], "fake-secret") + self.assertEqual(backend.entries[0]["api-key-entries"][1]["api-key"], "new-key") + + def test_add_preserves_existing_definitions(self): + backend = Management() + before = copy.deepcopy(backend.entries) + providers.upsert_provider(backend, {"name": "added", "url": "https://new.test/v1", "key": "new-key", "models": "model-new"}) + self.assertEqual(backend.entries[:2], before) + self.assertEqual([method for _, method, _ in backend.calls], ["GET", "PUT", "GET"]) + + def test_keyless_local_add(self): + backend = Management() + providers.upsert_provider(backend, {"name": "local", "url": "http://127.0.0.1:9000/v1", "key": "", "models": "local-model"}) + self.assertEqual(backend.entries[-1]["api-key-entries"], []) + + def test_remote_add_requires_key_even_with_weight(self): + for extra in ({}, {"weight": 2}): + backend = Management() + with self.assertRaisesRegex(ValueError, "API key"): + providers.upsert_provider(backend, {"name": "new", "url": "https://new.test/v1", "models": "model", **extra}, weights_supported=True) + self.assertTrue(all(method == "GET" for _, method, _ in backend.calls)) + + def test_keyless_local_edit_to_remote_requires_effective_key(self): + for extra in ({}, {"weight": 2}): + backend = Management() + backend.entries[0].update({"base-url": "http://localhost:9000/v1", "api-key-entries": []}) + with self.assertRaisesRegex(ValueError, "API key"): + providers.upsert_provider(backend, {"name": "first", "url": "https://new.test/v1", **extra}, weights_supported=True) + self.assertTrue(all(method == "GET" for _, method, _ in backend.calls)) + providers.upsert_provider(backend, {"name": "first", "url": "https://new.test/v1", "key": "new-key"}) + self.assertEqual(backend.entries[0]["api-key-entries"][0]["api-key"], "new-key") + + def test_create_only_cannot_edit_existing_provider(self): + backend = Management() + with self.assertRaisesRegex(ValueError, "already exists"): + providers.upsert_provider(backend, {"name": "first", "key": "new-key"}, create_only=True) + self.assertTrue(all(method == "GET" for _, method, _ in backend.calls)) + + def test_remove_only_exact_target(self): + backend = Management() + second = copy.deepcopy(backend.entries[1]) + providers.remove_provider(backend, "first") + self.assertEqual(backend.entries, [second]) + self.assertEqual(next(call for call in backend.calls if call[1] == "DELETE")[0], providers.ROUTE + "?name=first") + + def test_unknown_remove_and_duplicate_names_do_not_write(self): + for duplicate in (False, True): + backend = Management() + if duplicate: + backend.entries.append(copy.deepcopy(backend.entries[0])) + with self.assertRaises(ValueError): + providers.remove_provider(backend, "first" if duplicate else "absent") + self.assertTrue(all(method == "GET" for _, method, _ in backend.calls)) + + def test_url_rejects_remote_http_credentials_query_fragment_and_controls(self): + for url in ("http://example.test/v1", "https://user:key@example.test", "https://example.test?key=x", + "https://example.test#fragment", "https://example.test\\evil", "https://example.test/\n", "https://example.test:bad", "file:///x"): + with self.subTest(url=url), self.assertRaises(ValueError): + providers.validate_url(url) + self.assertEqual(providers.validate_url("http://[::1]:9000/v1/"), "http://[::1]:9000/v1") + + def test_invalid_fields_and_model_lists_never_write(self): + for patch in ({"weight": -1}, {"weight": True}, {"weight": 1000001}, {"models": []}, + {"models": ["same", "same"]}, {"models": ["model with space"]}, + {"disabled": "true"}, {"name": "first", "headers": {"Authorization": "bad"}}): + backend = Management() + with self.subTest(patch=patch), self.assertRaises(ValueError): + providers.upsert_provider(backend, {"name": "first", **patch}, weights_supported=True) + self.assertTrue(all(method == "GET" for _, method, _ in backend.calls)) + + def test_weights_rejected_on_unknown_backend(self): + backend = Management() + with self.assertRaisesRegex(ValueError, "unavailable"): + providers.upsert_provider(backend, {"name": "first", "weight": 2}) + self.assertTrue(all(method == "GET" for _, method, _ in backend.calls)) + + def test_explicit_test_makes_one_models_get_without_inference(self): + backend = Management() + backend.entries[0].pop("headers") + request = Mock(return_value={"data": [{"id": "model-a", "secret": "bad-metadata"}]}) + result = providers.test_provider(backend, "first", request) + request.assert_called_once_with("https://example.test/v1/models", key="fake-secret", method="GET", timeout=8) + self.assertEqual(result["provider_test"]["models"], ["model-a"]) + self.assertFalse(result["provider_test"]["inference_tested"]) + self.assertNotIn("secret", str(result)) + + def test_custom_headers_are_not_forwarded_by_test(self): + request = Mock() + with self.assertRaisesRegex(ValueError, "custom headers"): + providers.test_provider(Management(), "first", request) + request.assert_not_called() + + def test_test_error_body_or_key_is_never_returned(self): + backend = Management() + backend.entries[0].pop("headers") + error = urllib.error.HTTPError("https://example.test", 401, "fake-secret", {}, io.BytesIO(b"private body")) + with self.assertRaisesRegex(ValueError, "HTTP 401") as raised: + providers.test_provider(backend, "first", Mock(side_effect=error)) + self.assertNotIn("secret", str(raised.exception)) + self.assertIsNone(raised.exception.__cause__) + + def test_credential_readback_mismatch_does_not_claim_success(self): + backend = Management() + def api(route, method="GET", body=None): + return backend(route) if method == "PATCH" else backend(route, method, body) + with self.assertRaisesRegex(ValueError, "credential"): + providers.upsert_provider(api, {"name": "first", "weight": 0}, weights_supported=True) + + def test_readback_mismatch_does_not_claim_success(self): + backend = Management() + def api(route, method="GET", body=None): + return backend(route) if method == "PATCH" else backend(route, method, body) + with self.assertRaisesRegex(ValueError, "confirm"): + providers.upsert_provider(api, {"name": "first", "url": "https://changed.test/v1"}) + + +@unittest.skipUnless(os.environ.get("OMAPROXY_TEST_BINARY"), "set OMAPROXY_TEST_BINARY for isolated backend tests") +class ProviderBackendTests(unittest.TestCase): + def test_real_backend_crud_retains_secret_and_unrelated_provider(self): + from test_routing import isolated_backend + with isolated_backend() as (api, config): + providers.upsert_provider(api, {"name": "added", "url": "http://127.0.0.1:2/v1", + "key": "fake-added-key", "models": "added-model"}) + providers.upsert_provider(api, {"name": "mock", "key": "", "url": "http://127.0.0.1:3/v1", "disabled": False}) + raw = api(providers.ROUTE)["openai-compatibility"] + mock = next(entry for entry in raw if entry["name"] == "mock") + self.assertEqual(mock["api-key-entries"][0]["api-key"], "fake-upstream-key") + providers.upsert_provider(api, {"name": "mock", "weight": 0}, weights_supported=True) + raw = api(providers.ROUTE)["openai-compatibility"] + mock = next(entry for entry in raw if entry["name"] == "mock") + self.assertEqual(mock["api-key-entries"][0]["weight"], 0) + providers.remove_provider(api, "added") + self.assertEqual([entry["name"] for entry in api(providers.ROUTE)["openai-compatibility"]], ["mock"]) + self.assertNotIn("fake-upstream-key", json.dumps(providers.list_providers(api))) + self.assertIn("fixture comment must survive", config.read_text()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_quota_alerts.py b/tests/test_quota_alerts.py new file mode 100644 index 0000000..dc50ed3 --- /dev/null +++ b/tests/test_quota_alerts.py @@ -0,0 +1,211 @@ +import json +import os +from pathlib import Path +import sys +import tempfile +import unittest +from unittest.mock import patch +from types import SimpleNamespace + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import quota_alerts + + +NOW = 1_800_000_000 + + +def snapshot(*, remaining=8, reset_at=NOW + 3600, checked_at=NOW, + updated_at=NOW, status="ok", stale=False, error="", disabled=False, + label="Codex Spark · 5-hour", provider="codex", auth_index="private-account-id", + email="person@example.com", windows=None): + return {"checked_at": checked_at, "accounts": [{ + "name": "person-auth.json", "auth_index": auth_index, "email": email, + "provider": provider, "status": status, "checked_at": checked_at, + "updated_at": updated_at, "stale": stale, "error": error, + "disabled": disabled, + "windows": windows if windows is not None else [{ + "label": label, "remaining_percent": remaining, "reset_at": reset_at, + }], + }]} + + +class QuotaAlertTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory(prefix="omaproxy-alerts-") + self.config = Path(self.temp.name) / "config" + self.calls = [] + + def tearDown(self): + self.temp.cleanup() + + def runner(self, returncode=0): + def call(argv, **kwargs): + self.calls.append((argv, kwargs)) + return SimpleNamespace(returncode=returncode) + return call + + def notify_send(self): + return patch.object(quota_alerts.shutil, "which", return_value="/usr/bin/notify-send") + + def test_low_quota_uses_generic_safe_labels_and_private_state(self): + with self.notify_send(), patch.dict(os.environ, {"QUOTA_ALERT_TEST_SECRET": "do-not-forward"}): + result = quota_alerts.process(snapshot(), self.config, NOW, self.runner()) + + self.assertEqual(result, {"alert_count": 1}) + argv, kwargs = self.calls[0] + self.assertEqual(argv[0], "/usr/bin/notify-send") + self.assertIn("Codex · 5-hour: 8% remaining.", argv[-1]) + joined = json.dumps(argv) + self.assertNotIn("person@example.com", joined) + self.assertNotIn("person-auth.json", joined) + self.assertNotIn("private-account-id", joined) + self.assertEqual(kwargs["timeout"], 5) + self.assertIs(kwargs["shell"], False) + self.assertNotIn("QUOTA_ALERT_TEST_SECRET", kwargs["env"]) + state_path = self.config / "quota-alerts.json" + self.assertEqual(state_path.stat().st_mode & 0o777, 0o600) + state = json.loads(state_path.read_text()) + self.assertLessEqual(len(state["entries"]), quota_alerts.MAX_STATE_ENTRIES) + self.assertNotIn("private-account-id", state_path.read_text()) + self.assertNotIn("person@example.com", state_path.read_text()) + self.assertNotIn("Spark", state_path.read_text()) + + def test_same_low_quota_window_is_sent_once(self): + with self.notify_send(): + first = quota_alerts.process(snapshot(), self.config, NOW, self.runner()) + second_snapshot = snapshot(checked_at=NOW + 30, updated_at=NOW + 30) + second = quota_alerts.process(second_snapshot, self.config, NOW + 30, self.runner()) + + self.assertEqual(first["alert_count"], 1) + self.assertEqual(second["alert_count"], 0) + self.assertEqual(len(self.calls), 1) + + def test_reset_notice_requires_observed_reset_change_and_more_allowance(self): + with self.notify_send(): + initial = quota_alerts.process( + snapshot(remaining=30, reset_at=NOW + 3600), self.config, NOW, self.runner()) + changed = quota_alerts.process( + snapshot(remaining=70, reset_at=NOW + 7200, checked_at=NOW + 60, + updated_at=NOW + 60), self.config, NOW + 60, self.runner()) + + self.assertEqual(initial["alert_count"], 0) + self.assertEqual(changed["alert_count"], 1) + self.assertIn("allowance refreshed", self.calls[0][0][-1]) + + def test_timer_or_allowance_increase_without_reset_change_does_not_alert_reset(self): + with self.notify_send(): + quota_alerts.process(snapshot(remaining=30), self.config, NOW, self.runner()) + changed = quota_alerts.process( + snapshot(remaining=80, checked_at=NOW + 60, updated_at=NOW + 60), + self.config, NOW + 60, self.runner()) + + self.assertEqual(changed["alert_count"], 0) + self.assertEqual(self.calls, []) + + def test_unknown_previous_reset_does_not_prove_a_reset_transition(self): + with self.notify_send(): + quota_alerts.process(snapshot(remaining=30, reset_at=None), + self.config, NOW, self.runner()) + changed = quota_alerts.process( + snapshot(remaining=80, reset_at=NOW + 7200, + checked_at=NOW + 60, updated_at=NOW + 60), + self.config, NOW + 60, self.runner()) + + self.assertEqual(changed["alert_count"], 0) + self.assertEqual(self.calls, []) + + def test_stale_old_error_disabled_unknown_and_nonfinite_readings_are_ignored(self): + invalid_snapshots = [ + snapshot(checked_at=NOW - quota_alerts.MAX_DATA_AGE_SECONDS - 1, + updated_at=NOW - quota_alerts.MAX_DATA_AGE_SECONDS - 1), + snapshot(stale=True), + snapshot(error="quota request failed"), + snapshot(disabled=True), + snapshot(remaining=None), + snapshot(remaining="NaN"), + snapshot(remaining="Infinity"), + snapshot(remaining=True), + snapshot(label="untrusted account@example.com"), + ] + results = [] + with self.notify_send(): + for data in invalid_snapshots: + results.append(quota_alerts.process(data, self.config, NOW, self.runner())) + + self.assertTrue(all(result["alert_count"] == 0 for result in results)) + self.assertEqual(self.calls, []) + + def test_quota_network_error_does_not_become_an_authentication_alert(self): + data = snapshot(status="ok", error="401 sign in again to refresh quota") + with self.notify_send(): + result = quota_alerts.process(data, self.config, NOW, self.runner()) + self.assertEqual(result["alert_count"], 0) + self.assertEqual(self.calls, []) + + def test_only_explicit_health_status_emits_generic_auth_alert(self): + with self.notify_send(): + ignored = quota_alerts.process(snapshot(status="error", remaining=50), + self.config, NOW, self.runner()) + also_ignored = quota_alerts.process(snapshot(status="failed", remaining=50, + checked_at=NOW + 1, updated_at=NOW + 1), + self.config, NOW + 1, self.runner()) + failed = quota_alerts.process(snapshot(status="auth_error", remaining=50, + checked_at=NOW + 1, updated_at=NOW + 1), + self.config, NOW + 1, self.runner()) + + self.assertEqual(ignored["alert_count"], 0) + self.assertEqual(also_ignored["alert_count"], 0) + self.assertEqual(failed["alert_count"], 1) + self.assertIn("Codex account needs sign-in.", self.calls[0][0][-1]) + self.assertNotIn("person@example.com", json.dumps(self.calls[0][0])) + + def test_failed_delivery_retries_and_success_is_marked_sent(self): + with self.notify_send(): + failed = quota_alerts.process(snapshot(), self.config, NOW, self.runner(1)) + retried = quota_alerts.process( + snapshot(checked_at=NOW + 30, updated_at=NOW + 30), + self.config, NOW + 30, self.runner(0)) + duplicate = quota_alerts.process( + snapshot(checked_at=NOW + 60, updated_at=NOW + 60), + self.config, NOW + 60, self.runner(0)) + + self.assertEqual(failed["alert_count"], 0) + self.assertIn("could not be delivered", failed["error"]) + self.assertEqual(retried["alert_count"], 1) + self.assertEqual(duplicate["alert_count"], 0) + self.assertEqual(len(self.calls), 2) + + def test_missing_notify_send_reports_unsupported_once_and_keeps_pending(self): + with patch.object(quota_alerts.shutil, "which", return_value=None): + first = quota_alerts.process(snapshot(), self.config, NOW, self.runner()) + second = quota_alerts.process( + snapshot(checked_at=NOW + 30, updated_at=NOW + 30), + self.config, NOW + 30, self.runner()) + + self.assertIn("notify-send not found", first["error"]) + self.assertNotIn("error", second) + self.assertEqual(self.calls, []) + pending = [entry for entry in json.loads((self.config / "quota-alerts.json").read_text())["entries"].values() + if entry.get("delivery") == "pending"] + self.assertEqual(len(pending), 1) + + def test_state_is_bounded_to_two_hundred_entries(self): + data = {"checked_at": NOW, "accounts": []} + for index in range(MAX_STATE_ENTRIES := quota_alerts.MAX_STATE_ENTRIES + 5): + row = snapshot(remaining=50, auth_index=f"account-{index}")["accounts"][0] + data["accounts"].append(row) + with self.notify_send(): + quota_alerts.process(data, self.config, NOW, self.runner()) + stored = json.loads((self.config / "quota-alerts.json").read_text()) + self.assertLessEqual(len(stored["entries"]), MAX_STATE_ENTRIES) + + def test_stale_top_level_snapshot_does_not_create_or_update_state(self): + result = quota_alerts.process( + snapshot(checked_at=NOW - quota_alerts.MAX_DATA_AGE_SECONDS - 1), + self.config, NOW, self.runner()) + self.assertEqual(result, {"alert_count": 0}) + self.assertFalse(self.config.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_request_bounds.py b/tests/test_request_bounds.py new file mode 100644 index 0000000..5c34394 --- /dev/null +++ b/tests/test_request_bounds.py @@ -0,0 +1,30 @@ +import io +from pathlib import Path +import sys +import unittest +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import omaproxy + + +class ResponseBoundsTests(unittest.TestCase): + def request(self, body): + response = io.BytesIO(body) + opener = Mock() + opener.open.return_value = response + with patch.object(omaproxy, "REQUEST_MAX_BYTES", 32), \ + patch.object(omaproxy.urllib.request, "build_opener", return_value=opener): + return omaproxy.request("http://127.0.0.1:18317/v0/management/auth-files", "fake-key") + + def test_exact_limit_json_is_accepted(self): + self.assertEqual(self.request(b'"' + b'x' * 30 + b'"'), 'x' * 30) + + def test_oversized_response_fails_before_json_decoding_without_echoing_body(self): + with self.assertRaisesRegex(ValueError, "JSON size limit") as error: + self.request(b'{"secret": "' + b'x' * 100 + b'"}') + self.assertNotIn("secret", str(error.exception)) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_routing.py b/tests/test_routing.py new file mode 100644 index 0000000..207faf5 --- /dev/null +++ b/tests/test_routing.py @@ -0,0 +1,220 @@ +"""Routing requests must preserve credentials and unrelated config fields.""" +import copy +import contextlib +import json +import os +import socket +import subprocess +import tempfile +import time +import urllib.request +import io +from pathlib import Path +import sys +import unittest +import urllib.error + +sys.path.insert(0, str(Path(__file__).parents[1] / "scripts")) +import routing + + +class Management: + def __init__(self, v8=False): + self.v8, self.calls = v8, [] + self.config = {"api-keys": ["fake-client-secret"], "opaque": {"keep": True}, + "routing": {"strategy": "round-robin", "session-affinity": False, + "session-affinity-ttl": "1h", "session-affinity-subagents": True, + "opaque": "keep"}, + "request-retry": 3, "max-retry-credentials": 0, "max-retry-interval": 30} + if v8: + self.config["routing"]["retry"] = {field: self.config[field] for field in routing.SCALARS} + self.config["routing"]["cooldown"] = {"disable-cooling": False, "save-cooldown-status": True} + + def __call__(self, route, method="GET", body=None): + self.calls.append((route, method, copy.deepcopy(body))) + if route == routing.V8_ROUTING: + if not self.v8: + raise urllib.error.HTTPError(route, 404, "missing", {}, io.BytesIO()) + if method == "PATCH": + for field, value in body.items(): + if isinstance(value, dict): + self.config["routing"].setdefault(field, {}).update(value) + else: + self.config["routing"][field] = value + return {"status": "ok"} + return copy.deepcopy(self.config["routing"]) + if route.startswith("/v8/"): + raise urllib.error.HTTPError(route, 404, "missing", {}, io.BytesIO()) + if route == routing.V0 + "config": + return copy.deepcopy(self.config) + field = route.removeprefix(routing.V0) + if field == "routing/strategy": + if method == "PATCH": + self.config["routing"]["strategy"] = body["value"] + return {"strategy": self.config["routing"]["strategy"]} + if field in routing.SCALARS: + if method == "PATCH": + self.config[field] = body["value"] + return {field: self.config[field]} + raise AssertionError(route) + + +class RoutingTests(unittest.TestCase): + def test_v7_discovery_is_get_only_and_config_is_allowlisted(self): + backend = Management() + result = routing.read_settings(backend, "v7.2.154") + self.assertTrue(result["weights"]) + self.assertFalse(result["capabilities"]["session-affinity"]) + self.assertNotIn("fake-client-secret", str(result)) + self.assertNotIn("opaque", str(result)) + self.assertTrue(all(method == "GET" for _, method, _ in backend.calls)) + + def test_v7_omitted_affinity_fields_show_backend_defaults_read_only(self): + backend = Management() + for field in routing.AFFINITY: + backend.config["routing"].pop(field) + result = routing.read_settings(backend, "v7.2.154") + self.assertFalse(result["values"]["session-affinity"]) + self.assertEqual(result["values"]["session-affinity-ttl"], "1h") + self.assertTrue(result["values"]["session-affinity-subagents"]) + self.assertFalse(result["capabilities"]["session-affinity"]) + + def test_unknown_and_old_backends_do_not_advertise_weighted(self): + for version in (None, "custom", "v7.2.153"): + with self.subTest(version=version): + self.assertNotIn("weighted-round-robin", routing.read_settings(Management(), version)["strategies"]) + + def test_weighted_current_strategy_is_evidence_on_custom_backend(self): + backend = Management() + backend.config["routing"]["strategy"] = "weighted-round-robin" + self.assertTrue(routing.read_settings(backend)["weights"]) + + def test_v7_scalar_changes_preserve_unrelated_settings(self): + backend = Management() + routing.update_settings(backend, {"strategy": "weighted-round-robin", "request-retry": 2}, "v7.2.154") + self.assertEqual(backend.config["api-keys"], ["fake-client-secret"]) + self.assertEqual(backend.config["routing"]["opaque"], "keep") + self.assertEqual([route for route, method, _ in backend.calls if method == "PATCH"], + [routing.V0 + "routing/strategy", routing.V0 + "request-retry"]) + + def test_v7_unsupported_field_aborts_before_any_write(self): + backend = Management() + with self.assertRaisesRegex(ValueError, "safely edit"): + routing.update_settings(backend, {"request-retry": 2, "session-affinity": True}, "v7.2.154") + self.assertTrue(all(method == "GET" for _, method, _ in backend.calls)) + + def test_v8_edits_only_routing_with_one_merge_patch(self): + backend = Management(True) + changes = {"session-affinity": True, "session-affinity-ttl": "2h30m", + "session-affinity-subagents": False, "request-retry": 2, + "max-retry-credentials": 4, "max-retry-interval": 10, "disable-cooling": False} + routing.update_settings(backend, changes, "v8.0.13") + writes = [(route, body) for route, method, body in backend.calls if method != "GET"] + self.assertEqual(len(writes), 1) + self.assertEqual(writes[0][0], routing.V8_ROUTING) + self.assertEqual(writes[0][1]["retry"]["request-retry"], 2) + self.assertEqual(backend.config["opaque"], {"keep": True}) + self.assertTrue(backend.config["routing"]["cooldown"]["save-cooldown-status"]) + + def test_invalid_values_never_call_backend(self): + for changes in ({}, {"api-keys": []}, {"request-retry": True}, {"request-retry": -1}, + {"request-retry": 11}, {"max-retry-credentials": 101}, + {"max-retry-interval": 301}, {"session-affinity": "true"}, + {"session-affinity-ttl": "0s"}, {"session-affinity-ttl": "25h"}, + {"session-affinity-ttl": "NaNh"}, {"strategy": "priority"}): + with self.subTest(changes=changes): + backend = Management() + with self.assertRaises(ValueError): + routing.update_settings(backend, changes) + self.assertFalse(backend.calls) + + def test_readback_mismatch_does_not_claim_success(self): + backend = Management() + def api(route, method="GET", body=None): + return backend(route) if method == "PATCH" else backend(route, method, body) + with self.assertRaisesRegex(ValueError, "confirm"): + routing.update_settings(api, {"request-retry": 2}, "v7.2.154") + + def test_v7_partial_write_reports_applied_fields_without_error_body(self): + backend = Management() + def api(route, method="GET", body=None): + if method == "PATCH" and route.endswith("request-retry"): + raise ValueError("fake-secret-response") + return backend(route, method, body) + with self.assertRaisesRegex(ValueError, "already applied: strategy") as error: + routing.update_settings(api, {"strategy": "fill-first", "request-retry": 2}, "v7.2.154") + self.assertNotIn("fake-secret", str(error.exception)) + + def test_auth_failure_is_not_misreported_as_missing_capability(self): + def api(*args): + raise urllib.error.HTTPError("local", 401, "unauthorized", {}, io.BytesIO()) + with self.assertRaises(urllib.error.HTTPError) as error: + routing.read_settings(api) + error.exception.close() + + +@contextlib.contextmanager +def isolated_backend(): + """Temporary config, separate listener, no live service/config mutations.""" + with tempfile.TemporaryDirectory() as temp: + with contextlib.closing(socket.socket()) as sock: + sock.bind(("127.0.0.1", 0)) + port = sock.getsockname()[1] + config = Path(temp) / "config.yaml" + config.write_text("# fixture comment must survive\n" + + f"host: 127.0.0.1\nport: {port}\nauth-dir: {temp}/auth\n" + + "api-keys: [fake-client-key]\n" + + "remote-management: {secret-key: fake-management-key, allow-remote: false, disable-control-panel: true}\n" + + "request-retry: 3 # retry comment\nmax-retry-credentials: 2\nmax-retry-interval: 30\n" + + "routing: {strategy: round-robin, session-affinity: false, session-affinity-ttl: 1h}\n" + + "ws-auth: true # unrelated comment\n" + + 'openai-compatibility: [{name: mock, base-url: "http://127.0.0.1:1/v1", api-key-entries: [{api-key: fake-upstream-key, weight: 4}], models: [{name: mock-model, alias: mock-alias}]}]\n') + process = subprocess.Popen([os.environ["OMAPROXY_TEST_BINARY"], "--config", str(config), "--local-model"], + cwd=temp, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + def api(route, method="GET", body=None): + request = urllib.request.Request(f"http://127.0.0.1:{port}" + route, + method=method, data=None if body is None else json.dumps(body).encode(), + headers={"Authorization": "Bearer fake-management-key", "Content-Type": "application/json"}) + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + with opener.open(request, timeout=4) as response: + return json.load(response) + try: + for _ in range(100): + if process.poll() is not None: + raise RuntimeError("Isolated backend exited.") + try: + api(routing.V0 + "routing/strategy") + break + except (OSError, urllib.error.URLError): + time.sleep(0.1) + else: + raise RuntimeError("Isolated backend did not become ready.") + yield api, config + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + + +@unittest.skipUnless(os.environ.get("OMAPROXY_TEST_BINARY"), "set OMAPROXY_TEST_BINARY for isolated backend tests") +class RoutingBackendTests(unittest.TestCase): + def test_real_backend_strategy_retry_and_comment_preservation(self): + with isolated_backend() as (api, config): + discovered = routing.read_settings(api, "v7.2.154") + changes = {"strategy": "weighted-round-robin", "request-retry": 2, "max-retry-interval": 12} + if discovered["v8_config"]: + changes.update({"session-affinity": True, "session-affinity-ttl": "30m", + "session-affinity-subagents": False}) + routing.update_settings(api, changes, "v7.2.154") + self.assertTrue(api(routing.V0 + "ws-auth")["ws-auth"]) + saved = config.read_text() + for comment in ("fixture comment must survive", "retry comment", "unrelated comment"): + self.assertIn(comment, saved) + self.assertIn("fake-upstream-key", saved) + + +if __name__ == "__main__": + unittest.main()