diff --git a/CHANGELOG.md b/CHANGELOG.md index 2048ca1..248f5fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ All notable changes to OpenCode Model Control are recorded here. The project follows [Semantic Versioning](https://semver.org/). +## 0.4.0 + +- Treat empty SDK-default endpoints as unspecified rather than invalid, without letting a missing public URL certify a custom gateway. +- Accept OpenCode provider-owned authentication transports on Paid routes when the exact model binding matches. Do not inspect transport source or fall back to another billing connection. +- Add connection snapshots and settings schema 4. Existing Paid installs migrate to `verified-pricing`; selecting the new Paid control adopts `configured-connections`. +- Free routing still requires verified free prices. Configured Paid access can use a host route when public estimates are unavailable. +- Report tokens separately from OpenCode-recorded cost. Missing cost stays unreported. Quota may be not reported. Historical usage is not relabelled after a login change. +- Show configured connection billing declarations, evidence sources, exact role bindings, and separate captured usage by billing kind and currency. Connection edits require current revisions; drafts survive conflicts. +- Reload connection evidence in planner and MCP routes. Bound attribution work and retention, preserve assistant-message history and price snapshots, and flush on host disposal. +- Verify upgrades from the exact public 0.3.0 package under both Free and legacy Paid policy, including private backups and guarded update/recovery. +- Managed integration surface version 3; an explicit Connect/update and OpenCode restart is required for plugin behavior changes. + ## 0.3.0 - Replace historical free-model authorization with exact provider/model/API pricing evidence from credential-free public Models.dev metadata, including all supported supplied billing dimensions. Refresh every 15 minutes while active; expire evidence after 24 hours and block missing, conflicting, malformed, or expired pricing at dispatch. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 10d34fd..2663051 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ Documentation-only changes should still run `npm run verify` when practical. Sta 4. Update documentation when behavior, support, security, or benchmark claims change. 5. Run `npm run verify` and report any check you could not run. -Routing changes must keep unknown/expired pricing blocked and preserve the verified-free default. Saving Paid is the explicit authorization for known-paid routing; with auto-include on, it also authorizes future eligible known-paid models without a per-model click. Preserve explicit disables and never write inferred enrollment as saved intent. Public metadata cannot expand host-effective capabilities. Preserve existing ranking and defer authentication integrations unless separately approved. Connector changes must preserve unrelated OpenCode configuration, fail closed on ownership conflicts, and include isolated install/disconnect tests. +Routing changes must preserve the verified-free default. Free and migrated `verified-pricing` Paid still block unknown/expired public prices. After a user saves the new Paid control (`configured-connections`), a configured host route may be eligible without a public estimate; malformed endpoints and changed bindings stay blocked. Preserve explicit disables and never write inferred enrollment as saved intent. Public metadata cannot expand host-effective capabilities or certify a custom endpoint. Preserve existing ranking and defer authentication integrations unless separately approved. Connector changes must preserve unrelated OpenCode configuration, fail closed on ownership conflicts, and include isolated install/disconnect tests. Do not include credentials, private prompts, user transcripts, proprietary source code, benchmark data you cannot redistribute, or code copied from closed-source routers. Contributions must be clean-room work or compatible third-party material with its provenance and license recorded. diff --git a/README.md b/README.md index 10b04fb..c8a3c7b 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ The control panel runs on `127.0.0.1`. OpenCode remains responsible for provider The running app is authoritative for model names, availability, pricing evidence, and role eligibility. -> This source documents **0.3.0**; `@latest` installs the version currently published on [npm](https://www.npmjs.com/package/opencode-model-control). Check the [release index](https://github.com/BitL8-ByteShort/opencode-model-control/releases) for availability and the [support matrix](docs/support-matrix.md) for verified compatibility. +> This source documents **0.4.0**; `@latest` installs the version currently published on [npm](https://www.npmjs.com/package/opencode-model-control). Check the [release index](https://github.com/BitL8-ByteShort/opencode-model-control/releases) for availability and the [support matrix](docs/support-matrix.md) for verified compatibility. 0.4.0 is implemented in this tree; public publication is a separate authorized gate. ## What it does @@ -110,7 +110,7 @@ The connector writes absolute Node and package CLI paths, so a source checkout d The [GitHub release index](https://github.com/BitL8-ByteShort/opencode-model-control/releases) lists published versioned tarballs and checksums. Download the exact release asset, verify its SHA-256 against that release's checksum, then install the local file with `npm install --global /absolute/path/to/downloaded-package.tgz`. Historical package digests are recorded in the [historical package ledger](https://github.com/BitL8-ByteShort/opencode-model-control/blob/v0.2.1/packages/README.md). The [release checklist](docs/releasing.md) contains the maintainer-only 0.3.0 publication and verification procedure. -## What “Update available models” means (0.3.0) +## What “Update available models” means (0.4.0) The button asks the installed OpenCode CLI for its effective model list with plugin-aware discovery and `--refresh`. This reflects OpenCode's resolved provider configuration, including its provider and model filters. diff --git a/SECURITY.md b/SECURITY.md index fe13861..5113d9c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -40,7 +40,7 @@ The manual runtime access check is never automatic. It requires explicit provide Catalog refresh sends a credential-free request only to the fixed `https://models.dev/api.json` metadata endpoint, with JSON accept and conditional ETag/Last-Modified headers. Redirects are rejected; request time and response size are bounded (8 seconds, 32 MiB). No metadata-provided URL is fetched and no prompt, attachment, usage, selected-model list, local config, or provider credential is sent. Public metadata servers still receive ordinary request/network metadata. The private conditional cache carries an exact source digest and retrieval/expiry times; failed retrieval never extends the 24-hour pricing lifetime. Refresh checks run every 15 minutes while active and on stale startup or manual request. -Pricing is matched by the exact provider/full model key and API identity (model ID, npm adapter, and normalized endpoint). A similarly named model, a `-free` suffix, arbitrary CLI zeros, and bundled historical evidence cannot authorize free routing. Model Control fetches the fixed public `https://models.dev/api.json` endpoint without credentials; URLs inside metadata are never fetched. Complete, finite, nonnegative input/output rates are required. Every supported supplied billing dimension counts: reasoning, cache read/write, audio input/output, context tiers, legacy over-200k rates, and experimental modes. With complete valid evidence, any positive rate means paid; all supplied rates must be valid and exactly zero for free. Missing, malformed, unsupported, or conflicting evidence is unknown and blocked. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free. +Pricing is matched by the exact provider/full model key and API identity (model ID, npm adapter, and normalized endpoint). Raw empty, absent, and null URLs are unspecified SDK defaults, not a wildcard for custom endpoints. A similarly named model, a `-free` suffix, arbitrary CLI zeros, and bundled historical evidence cannot authorize free routing. Model Control fetches the fixed public `https://models.dev/api.json` endpoint without credentials; URLs inside metadata are never fetched. Complete, finite, nonnegative input/output rates are required. Every supported supplied billing dimension counts: reasoning, cache read/write, audio input/output, context tiers, legacy over-200k rates, and experimental modes. With complete valid evidence, any positive rate means paid; all supplied rates must be valid and exactly zero for free. Missing, malformed, unsupported, or conflicting evidence is unknown. Unknown prices cannot authorize Free or migrated verified-pricing Paid. After the user saves configured-connection Paid access, a configured host route may be used without a public estimate. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free, and CLI cost cannot override a public-price route mismatch. Provider-owned authentication transports are not inspected or logged. Pricing evidence expires after **24 hours**, checked at route time even without another refresh. Successful HTTP 200 or cached 304 revalidation renews public-source freshness; a failed attempt does not. Cached evidence remains usable only until its existing expiry. Public-source digests and timestamps describe retrieved metadata, not a billing guarantee or model-quality score. diff --git a/benchmarks/schemas/connection-store.schema.json b/benchmarks/schemas/connection-store.schema.json new file mode 100644 index 0000000..a2ebe3a --- /dev/null +++ b/benchmarks/schemas/connection-store.schema.json @@ -0,0 +1,204 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://opencode-model-control.local/schemas/connection-store.schema.json", + "title": "OpenCode Model Control Connection Snapshot", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "revision", + "scopeId", + "connections" + ], + "properties": { + "schemaVersion": { + "const": 1 + }, + "revision": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "scopeId": { + "type": "string", + "pattern": "^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", + "description": "Private installation scope UUID. Not a path, account name, or credential fingerprint." + }, + "connections": { + "type": "array", + "items": { + "$ref": "#/$defs/connection" + } + } + }, + "$defs": { + "hex32": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "quota": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "unit", + "limit", + "used", + "remaining", + "resetsAt", + "observedAt", + "expiresAt" + ], + "properties": { + "source": { + "enum": [ + "host", + "provider-adapter" + ] + }, + "unit": { + "enum": [ + "tokens", + "requests", + "credits", + "percent" + ] + }, + "limit": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "used": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "remaining": { + "type": [ + "number", + "null" + ], + "minimum": 0 + }, + "resetsAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + }, + "observedAt": { + "$ref": "#/$defs/timestamp" + }, + "expiresAt": { + "$ref": "#/$defs/timestamp" + } + } + }, + "connection": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "providerId", + "bindingRevision", + "authKind", + "billing", + "transportVisibility", + "inventoryObservedAt", + "entitlement", + "quota" + ], + "properties": { + "id": { + "$ref": "#/$defs/hex32" + }, + "providerId": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" + }, + "bindingRevision": { + "$ref": "#/$defs/hex32" + }, + "authKind": { + "enum": [ + "oauth", + "api-key", + "none", + "unknown" + ] + }, + "billing": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "source", + "observedAt" + ], + "properties": { + "kind": { + "enum": [ + "subscription", + "metered-api", + "prepaid", + "local", + "free", + "unknown" + ] + }, + "source": { + "enum": [ + "host", + "provider-adapter", + "user-declared", + "unknown" + ] + }, + "observedAt": { + "type": [ + "string", + "null" + ], + "format": "date-time" + } + } + }, + "transportVisibility": { + "enum": [ + "declared-endpoint", + "host-managed" + ] + }, + "inventoryObservedAt": { + "$ref": "#/$defs/timestamp" + }, + "entitlement": { + "enum": [ + "reported-active", + "reported-revoked", + "not-reported" + ] + }, + "quota": { + "anyOf": [ + { + "type": "null" + }, + { + "$ref": "#/$defs/quota" + } + ] + } + } + } + } +} diff --git a/benchmarks/schemas/route-plan.schema.json b/benchmarks/schemas/route-plan.schema.json index f41e138..15a737f 100644 --- a/benchmarks/schemas/route-plan.schema.json +++ b/benchmarks/schemas/route-plan.schema.json @@ -4,39 +4,93 @@ "title": "OpenCode Model Control Route Plan", "type": "object", "additionalProperties": false, - "required": ["schemaVersion", "route", "assignments", "policy", "reasons"], + "required": [ + "schemaVersion", + "route", + "assignments", + "policy", + "reasons" + ], "properties": { - "schemaVersion": { "const": 1 }, - "route": { "enum": ["direct", "orchestrator", "code-worker", "vision-worker", "reviewer"] }, + "schemaVersion": { + "const": 2 + }, + "route": { + "enum": [ + "direct", + "orchestrator", + "code-worker", + "vision-worker", + "reviewer" + ] + }, "assignments": { "type": "array", "minItems": 1, "maxItems": 4, - "items": { "$ref": "#/$defs/assignment" } + "items": { + "$ref": "#/$defs/assignment" + } }, "policy": { "type": "object", "additionalProperties": false, - "required": ["freeOnly", "costPreference", "costPolicy", "maxDelegationDepth", "maxFallbacksPerAssignment", "recursiveDelegation"], + "required": [ + "freeOnly", + "costPreference", + "costPolicy", + "maxDelegationDepth", + "maxFallbacksPerAssignment", + "recursiveDelegation", + "paidEligibility" + ], "properties": { - "freeOnly": { "type": "boolean" }, - "costPreference": { "enum": ["free-first", "paid-first"] }, - "costPolicy": { "enum": ["free-only", "known-cost"] }, - "maxDelegationDepth": { "type": "integer", "minimum": 0, "maximum": 1 }, + "freeOnly": { + "type": "boolean" + }, + "costPreference": { + "enum": [ + "free-first", + "paid-first" + ] + }, + "costPolicy": { + "enum": [ + "free-only", + "known-cost" + ] + }, + "maxDelegationDepth": { + "type": "integer", + "minimum": 0, + "maximum": 1 + }, "maxFallbacksPerAssignment": { "type": "integer", "minimum": 0, "maximum": 1, "description": "Legacy settings key for the maximum review-driven repair passes; it does not enable alternate-model fallback." }, - "recursiveDelegation": { "const": false } + "recursiveDelegation": { + "const": false + }, + "paidEligibility": { + "enum": [ + "verified-pricing", + "configured-connections" + ] + } } }, "reasons": { "type": "array", "minItems": 1, "maxItems": 8, - "items": { "type": "string", "minLength": 1, "maxLength": 128 } + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } } }, "$defs": { @@ -55,11 +109,22 @@ "selection", "access", "modalities", - "mayDelegate" + "mayDelegate", + "connectionId", + "bindingRevision" ], "properties": { - "role": { "enum": ["orchestrator", "code-worker", "vision-worker", "reviewer"] }, - "modelId": { "$ref": "#/$defs/modelId" }, + "role": { + "enum": [ + "orchestrator", + "code-worker", + "vision-worker", + "reviewer" + ] + }, + "modelId": { + "$ref": "#/$defs/modelId" + }, "fallbackModelId": { "type": "null", "deprecated": true, @@ -70,15 +135,49 @@ "deprecated": true, "description": "Legacy contract field retained for compatibility; alternate-model fallback is not executed." }, - "selection": { "enum": ["auto", "explicit"] }, - "access": { "enum": ["read", "write"] }, + "selection": { + "enum": [ + "auto", + "explicit" + ] + }, + "access": { + "enum": [ + "read", + "write" + ] + }, "modalities": { "type": "array", "minItems": 1, "uniqueItems": true, - "items": { "enum": ["text", "image", "audio", "video", "pdf"] } + "items": { + "enum": [ + "text", + "image", + "audio", + "video", + "pdf" + ] + } + }, + "mayDelegate": { + "type": "boolean" + }, + "connectionId": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-f0-9]{32}$" }, - "mayDelegate": { "type": "boolean" } + "bindingRevision": { + "type": [ + "string", + "null" + ], + "pattern": "^[a-f0-9]{32}$" + } } } } diff --git a/benchmarks/schemas/router-settings.schema.json b/benchmarks/schemas/router-settings.schema.json index 1be12ce..498b1c7 100644 --- a/benchmarks/schemas/router-settings.schema.json +++ b/benchmarks/schemas/router-settings.schema.json @@ -8,7 +8,10 @@ "schemaVersion", "costPreference", "costPolicy", + "paidEligibility", "roleAssignments", + "roleConnections", + "billingDeclarations", "maxDelegationDepth", "maxFallbacksPerAssignment", "makeRouterDefault", @@ -17,7 +20,7 @@ ], "properties": { "schemaVersion": { - "const": 3 + "const": 4 }, "costPreference": { "enum": [ @@ -31,6 +34,46 @@ "known-cost" ] }, + "paidEligibility": { + "enum": [ + "verified-pricing", + "configured-connections" + ] + }, + "roleConnections": { + "type": "object", + "additionalProperties": false, + "required": [ + "orchestrator", + "code-worker", + "vision-worker", + "reviewer" + ], + "properties": { + "orchestrator": { + "$ref": "#/$defs/roleConnection" + }, + "code-worker": { + "$ref": "#/$defs/roleConnection" + }, + "vision-worker": { + "$ref": "#/$defs/roleConnection" + }, + "reviewer": { + "$ref": "#/$defs/roleConnection" + } + } + }, + "billingDeclarations": { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "additionalProperties": { + "$ref": "#/$defs/billingDeclaration" + } + }, "roleAssignments": { "type": "object", "additionalProperties": false, @@ -84,6 +127,63 @@ } }, "$defs": { + "roleConnection": { + "oneOf": [ + { + "type": "null" + }, + { + "type": "object", + "additionalProperties": false, + "required": [ + "connectionId", + "bindingRevision" + ], + "properties": { + "connectionId": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "bindingRevision": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + } + } + } + ] + }, + "billingDeclaration": { + "type": "object", + "additionalProperties": false, + "required": [ + "kind", + "bindingRevision", + "source" + ], + "properties": { + "kind": { + "enum": [ + "subscription", + "metered-api", + "prepaid", + "local", + "free", + "unknown" + ] + }, + "bindingRevision": { + "type": "string", + "pattern": "^[a-f0-9]{32}$" + }, + "source": { + "const": "user-declared" + }, + "declaredAt": { + "type": "string", + "format": "date-time" + } + } + }, "assignment": { "oneOf": [ { diff --git a/docs/opencode-integration.md b/docs/opencode-integration.md index 62fd521..328c568 100644 --- a/docs/opencode-integration.md +++ b/docs/opencode-integration.md @@ -28,9 +28,13 @@ A refresh does not invoke a model, confirm an entitlement, prove successful prov Startup refreshes stale metadata before initialization completes; a live service checks every **15 minutes**, and **Update available models** can request an immediate refresh. A shared refresh lease coalesces panel/MCP processes; a recent persisted attempt prevents duplicate periodic work. OpenCode discovery and the independent public metadata fetch run concurrently. Failed or incomplete discovery retains the last usable model records; complete discovery can mark an absent model unavailable while preserving its identity and saved choices. The panel distinguishes last attempt, last successful discovery, and last successful pricing retrieval. A failed refresh cannot renew pricing freshness. Refresh does not invoke provider inference or rewrite OpenCode config; OpenCode itself may normalize its standard `$schema` field. -Pricing is matched by the exact provider/full model key and API identity (model ID, npm adapter, and normalized endpoint). A similarly named model, a `-free` suffix, arbitrary CLI zeros, and bundled historical evidence cannot authorize free routing. Model Control fetches the fixed public `https://models.dev/api.json` endpoint without credentials; URLs inside metadata are never fetched. Complete, finite, nonnegative input/output rates are required. Every supported supplied billing dimension counts: reasoning, cache read/write, audio input/output, context tiers, legacy over-200k rates, and experimental modes. With complete valid evidence, any positive rate means paid; all supplied rates must be valid and exactly zero for free. Missing, malformed, unsupported, or conflicting evidence is unknown and blocked. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free. +A **connection** is the configured OpenCode provider slot used to reach a model. Billing kind (subscription, metered API, prepaid, local, or unknown) is observed from host evidence or an explicit user declaration. Authentication method alone does not determine billing: an API key can be a coding-plan subscription, and OAuth is not proof of entitlement. -Pricing evidence expires after **24 hours**, checked at route time even without another refresh. Successful HTTP 200 or cached 304 revalidation renews public-source freshness; a failed attempt does not. Cached evidence remains usable only until its existing expiry. Public-source digests and timestamps describe retrieved metadata, not a billing guarantee or model-quality score. +Pricing is matched by the exact provider/full model key and API identity (model ID, npm adapter, and normalized endpoint). Raw empty, absent, and null URLs are unspecified SDK defaults; they are not invalid and are not a wildcard for custom gateways. A similarly named model, a `-free` suffix, arbitrary CLI zeros, and bundled historical evidence cannot authorize free routing. Model Control fetches the fixed public `https://models.dev/api.json` endpoint without credentials; URLs inside metadata are never fetched. Complete, finite, nonnegative input/output rates are required. Every supported supplied billing dimension counts: reasoning, cache read/write, audio input/output, context tiers, legacy over-200k rates, and experimental modes. With complete valid evidence, any positive rate means paid; all supplied rates must be valid and exactly zero for free. Missing, malformed, unsupported, or conflicting evidence is unknown. Unknown prices cannot authorize **Free** or migrated **verified-pricing Paid**. After the user saves the new Paid control (`configured-connections`), a configured host route may be used when estimates are unavailable. Complete positive CLI evidence can establish `reported-paid` when independent evidence does not contradict it; CLI zero cannot establish free, and CLI cost cannot override a public-price route mismatch. + +OpenCode owns execution transport. A provider-owned authentication `fetch` may be accepted on Paid routes when the exact selected provider/model and observable connection binding match. Transport visibility is host-managed in that case; Model Control does not claim to have verified the network destination. Task or model route overrides, changed endpoints, and opaque transports under Free policy remain blocked. There is no automatic fallback from a subscription connection to metered API billing. + +Pricing evidence expires after **24 hours** for Free and legacy verified-pricing access, checked at route time even without another refresh. Configured-connection Paid can continue with a stale-estimate warning. Successful HTTP 200 or cached 304 revalidation renews public-source freshness; a failed attempt does not. Cached evidence remains usable only until its existing expiry. Public-source digests and timestamps describe retrieved metadata, not a billing guarantee, subscription quota, or model-quality score. Quota is **Not reported** unless a supported host adapter actually exposes it. Historical OpenCode usage is not classified from today's login. ## Managed config surface @@ -70,7 +74,7 @@ The top-level plugin entry is a canonical absolute `file://` URL to the installe The connector does not add provider configuration or API keys. When **Make Omc-Router my default agent** is enabled, it adds `default_agent: "omc-router"` only if the OpenCode config has no default. An existing user-owned default is preserved. If this installation previously added the default, disabling the option on a later Connect removes only that receipt-owned value. -The generated config disables `model-control_*` globally and opts only `omc-router` back in. Specialists deny those tools and further delegation. The code worker retains bounded implementation tools, while the independent reviewer is limited to read/search tools and has no shell, edit, or write permission. Managed surface version 2 always generates all four model-free agent definitions. A vision model is selected at dispatch only when current effective text output, tool calls, and the actual media input are confirmed. +The generated config disables `model-control_*` globally and opts only `omc-router` back in. Specialists deny those tools and further delegation. The code worker retains bounded implementation tools, while the independent reviewer is limited to read/search tools and has no shell, edit, or write permission. Managed surface version 3 always generates all four model-free agent definitions. A vision model is selected at dispatch only when current effective text output, tool calls, and the actual media input are confirmed. OpenCode's documented surfaces are the source of truth: @@ -121,12 +125,12 @@ If automatic rollback reports that it could not restore the config, stop making ## Generated team -| Agent | Mode | Initial intent | -| --- | --- | --- | -| `omc-router` | Primary | Text planning, policy lookup, and bounded delegation using saved policy | -| `omc-code-worker` | Subagent | Bounded implementation and one possible review-driven repair | +| Agent | Mode | Initial intent | +| ------------------- | -------- | ----------------------------------------------------------------------------------------- | +| `omc-router` | Primary | Text planning, policy lookup, and bounded delegation using saved policy | +| `omc-code-worker` | Subagent | Bounded implementation and one possible review-driven repair | | `omc-vision-worker` | Subagent | Media-capable, tool-call-capable model assignment that also powers Omc-Router media turns | -| `omc-reviewer` | Subagent | Independent read-only text or code review; no shell, edit, or write permission | +| `omc-reviewer` | Subagent | Independent read-only text or code review; no shell, edit, or write permission | Role choices are not benchmark winners. Automatic selection requires discovery, availability, Model Control enablement, permitted pricing, compatible modality, required access/tool capability, and a positive role profile. @@ -143,9 +147,9 @@ The stored settings intentionally separate priority and permission: } ``` -**Free** uses `free-first + free-only`. **Paid** uses `paid-first + known-cost`. Known-cost mode allows both verified-free and known-paid candidates; it does not make unknown pricing eligible. +**Free** uses `free-first + free-only` and requires verified-free pricing. **Paid** uses `paid-first + known-cost`. Migrated Paid settings retain `paidEligibility: "verified-pricing"`, which allows verified-free and known-paid candidates. Saving the new Paid control explicitly adopts `configured-connections`, allowing configured host routes with unavailable estimates while preserving connection, availability, capability, and disable guards. -**Automatically include new models** defaults on. A model with `selection: "policy"` (including an absent control) follows that setting and the saved Free/Paid policy. Free permits current verified-free evidence only; Paid permits known-paid and verified-free models and prefers paid after hard gates. Saving Paid with auto-include on authorizes future eligible known-paid models without a separate click for every new model. Turning auto-include off excludes policy-following models; explicit enables still apply. An explicit disable always wins. An enable or role pin cannot bypass unknown/expired pricing, availability, capabilities, or cost policy. +**Automatically include new models** defaults on. A model with `selection: "policy"` (including an absent control) follows that setting and the saved Free/Paid policy. Free permits current verified-free evidence only; migrated Paid permits known-paid and verified-free models and prefers paid after hard gates. Saving configured-connection Paid with auto-include on authorizes future eligible configured routes without a separate click for every new model. Turning auto-include off excludes policy-following models; explicit enables still apply. An explicit disable always wins. An enable or role pin cannot bypass availability, capabilities, connection identity, or the selected cost policy; Free and verified-pricing Paid also require current pricing. Selecting a compatible role model can explicitly enable it in the draft; selecting Automatic changes the role choice without writing inferred model enables. **Save changes** commits user intent. Refresh never adds inferred controls or rewrites saved intent. @@ -165,7 +169,7 @@ Owned slash subtasks have a narrow, one-shot allowance for OpenCode's synthetic ## Saved state and migration -Settings schema v3 stores intent as `selection: "policy" | "enabled" | "disabled"`, plus optional user availability exclusions; effective eligibility is derived separately. Legacy v0/v1/v2 Boolean controls migrate to explicit choices while preserving disables, Paid policy, pins (including absent model IDs), workflow bounds, and default-agent preference. Migration first saves an exact private `settings.json.v.backup-` copy, then atomically writes v3. State directories use mode `0700`; settings, cache, snapshot, status, migration backups, and receipts use `0600`. +Settings schema v4 stores intent as `selection: "policy" | "enabled" | "disabled"`, plus optional user availability exclusions; effective eligibility is derived separately. Legacy v0/v1/v2 Boolean controls and v3 selections migrate to explicit choices while preserving disables, Paid policy, pins (including absent model IDs), workflow bounds, and default-agent preference. Migration first saves an exact private `settings.json.v.backup-` copy, then atomically writes v4. Free and Paid migrations retain `verified-pricing`; v3 auto-inclusion, explicit disables, and absent-model pins survive. Role connection bindings and billing declarations are stored separately from model choices. The exact public 0.3.0 package is the primary upgrade baseline; its managed surface 2 requires an explicit guarded connection update to surface 3 and an OpenCode restart. State directories use mode `0700`; settings, cache, snapshot, status, migration backups, and receipts use `0600`. Settings and catalog reads/writes share a cross-process lock. Save uses the last settings revision for compare-and-swap: a settings conflict returns 409 without overwriting either writer. A catalog-only change can rebase untouched choices, but newly edited ineligible selections return a selection conflict. Existing blocked pins remain visible through unrelated edits. The panel preserves unsaved drafts during refresh and conflicts so the user can review and retry. Corrupt saved state fails closed; preserve the private state and migration backup for recovery rather than deleting disables or replacing the whole state with defaults. Full OpenCode config backups are a separate connector recovery mechanism. diff --git a/docs/plans/2026-09-08-connection-billing-usage-execution-log.md b/docs/plans/2026-09-08-connection-billing-usage-execution-log.md new file mode 100644 index 0000000..f06001a --- /dev/null +++ b/docs/plans/2026-09-08-connection-billing-usage-execution-log.md @@ -0,0 +1,120 @@ +# Connection Billing Execution Log + +**Branch:** `codex/connection-billing-usage` +**Baseline HEAD:** `b931cbc3218b028c5060aaa3eb7997d189dc9ea3` (`main`, package 0.3.0) +**Started:** 2026-09-08 +**Executor:** Grok 4.6 + +## Environment + +| Item | Value | +| --- | --- | +| Node | v26.8.1 (`/home/panda927/.local/bin/node`) | +| npm | 11.19.0 | +| OpenCode | 1.18.28 | +| Platform | linux | +| Remote | `https://github.com/BitL8-ByteShort/opencode-model-control.git` | + +Node is not 22.12.0 or 24.x. Recorded as an environment limitation; diagnose product vs environment failures separately. `engines` is `>=22.12.0`. + +## Preserved working-tree patch + +Saved to `docs/plans/2026-09-08-working-tree-preserved.patch` (82 lines). Original uncommitted files: + +- `src/core/pricing.js` +- `src/server/opencode-cli.js` + +**Disposition:** both hunks **excluded** from the worktree. + +- `pricing.js`: loosened public-URL matching so a live URL is accepted when models.dev omitted the URL. Plan §1.1 / §3.3: an unknown public endpoint must not certify an arbitrary custom endpoint. +- `opencode-cli.js`: allowed positive CLI costs to override an identity conflict. Plan §1.1: positive recorded costs do not prove matching route identity. + +These are not verified fixes. The correct Task 2 contract treats raw `''`/absent/null as an unspecified SDK default without relaxing public-price identity. + +## Task 1 + +- `git status --short` at start: `M src/core/pricing.js`, `M src/server/opencode-cli.js`, untracked plan. +- HEAD `b931cbc3218b028c5060aaa3eb7997d189dc9ea3`, package 0.3.0, branch created `codex/connection-billing-usage`. +- Patch saved; both hunks excluded (see above). +- `npm ci`: 44 packages, 0 vulnerabilities. +- `npm run verify` on Node v26.8.1: 2 failures (production-entry timeout; lock recovery `typeof release` object vs function). Environment, not product. +- `npm run verify` on `/usr/bin/node` v24.14.0: 292 pass, 1 fail — `test/server/production-entry.test.js` times out waiting for `/OpenCode Model Control:/`. Only output is Node `--experimental-loader` deprecation warning. Pre-existing environment limitation. Subsequent work uses Node 24. +- Failing regressions added and confirmed: + - empty `url: ''` => `urlValid: false` (`{"url":""}` false !== true) + - `xai/grok-4.6` empty SDK default => `unknown` !== `paid` + - CLI parse empty URL `urlValid` false !== true + - cached invalid then fresh empty discovery still `urlValid` false + - provider-owned fetch => `OMC_DISPATCH_IDENTITY_CONFLICT` +- Guard that already passed on HEAD: custom gateway cannot inherit unspecified public rates / CLI cost cannot override mismatch. + +## Task 2 + +**Files:** `src/core/pricing.js`, `src/server/opencode-cli.js`, `test/core/pricing.test.js`, `test/server/opencode-cli.test.js`, `test/server/catalog-v2.test.js`, `test/fixtures/public-metadata.js` + +**Fix:** Treat raw `''` / absent / null as unspecified SDK default (`url: null`, `urlValid: true`) unless cached `urlValid: false`. Malformed, whitespace, credential, and query endpoints remain invalid. Public rate matching uses exact id/npm/url; missing public URL does not certify a custom gateway (`public-price-route-mismatch`). CLI cost cannot override that mismatch. + +**Commands:** `node --test test/core/pricing.test.js test/server/opencode-cli.test.js test/server/models-dev.test.js test/server/catalog-v2.test.js` — 38 pass, 0 fail. + +**Remaining:** provider-owned fetch still blocked (Task 5). Fetch regression left unstaged for that task. + +## Task 3 + +**Files:** `src/core/connections.js`, `src/server/connection-store.js`, `src/opencode/connection-observer.js`, settings v4, schemas, snapshot wiring. + +**Result:** Existing Paid migrates to `paidEligibility: verified-pricing`. Fresh installs stay Free. One host slot is one connection; API keys are not auto-labelled metered API. `node --test` on connection/settings/schema tests: 49 pass. + +**Commit:** `ef07545` + +## Task 4 + +**Files:** `src/core/eligibility.js`, `src/core/catalog.js`, `src/server/service.js` + +**Result:** Shared `resolveEligibility`. Configured-connection Paid allows unknown estimates; Free and legacy Paid stay verified-price gated. Invalid endpoints and binding changes still block. + +**Commit:** `55901ca` + +## Task 5 + +**Files:** `src/opencode/plugin-runtime.js`, live-routing tests + +**Result:** Provider-level opaque `fetch` accepted under Paid when binding matches. Free still rejects it. Model/task fetch and endpoint overrides still conflict. Repair retains connection ID/revision and stops on a subscription-to-API binding change. + +**Commit:** `87be12d` + +## Task 6 + +**Files:** `src/core/usage-accounting.js`, `src/server/usage-attribution-store.js`, `src/server/opencode-usage.js`, plugin dispatch capture. + +**Result:** Missing cost/tokens stay null. Usage schema 2 labels OpenCode-recorded cost. Quota may be not reported. Attribution store uses private HMAC keys, 90-day/10k/10MiB bounds. Plugin records pending observations without blocking dispatch. + +## Task 7 + +**Files:** Model table connection/access/pricing columns, Paid adoption notice, usage panel labels. + +## Task 8 + +Package 0.4.0, managed surface 3, changelog, CONTRIBUTING, integration, SECURITY, README. + +Acceptance on this Linux/Node 24 host: +- `OMC_HOST_BINARY=opencode npm run test:host`: passed, OpenCode 1.18.28, 19 scenarios, 56 loopback requests, 0 real-provider inference. +- `npm run test:browser`: 12 passed. +- `npm run test:metadata`: passed, 0 inference requests. +- OpenCode 1.18.22 binary not present; macOS and packaged four-way Node/OS gates not run. +- `production-entry` still times out on Node 24 `--experimental-loader` (pre-existing environment). +- No public publish. PR review is separate from publication. + + +## Astra completion of PR #17 (2026-09-08 America/New_York) + +Baseline: `26c4dd912aafa33a932a40fc4d0d9991d7e7b4fa`. Completed the four remaining implementation areas: configured-connection billing/declaration and historical usage UI; connection-aware planner/MCP with revision-safe edits; bounded immutable attribution and disposal; exact public 0.3.0 Free/Paid migration acceptance. Route-plan contract is now schema 2. Catalog remains schema 2 because connection evidence is a separate versioned store. + +- New explicit pins require both a current connection revision and an exact connection binding. Unchanged legacy pins and unrelated edits retain their existing intent. Billing declarations cannot override host/provider-adapter evidence. +- Captured assistant usage retains dispatch billing and public rate evidence; missing token semantics leaves estimates unreported. Captured usage is partial and is never added to aggregate host costs. +- Attribution has bounded queued work, private salted identities, immutable completion upserts, validated stored rows, cumulative failures/drops and an awaited, bounded host disposal flush. +- Browser interaction suite: 14 scenarios passed before the final coverage-field alignment; exact candidate acceptance reruns all 14 against installed production assets. +- Node 24.14.0: type check/build passed; all 343 tests passed with no skips using `node --test --test-concurrency=4` and exact OpenCode 1.18.22 on PATH. Initial unconstrained run hit two host startup timeouts. Both passed independently; production-entry was then corrected to use an isolated discovery fixture instead of inheriting the maintainer's OpenCode state. Real-host behavior remains covered by the separate host suite. +- Independent Astra high review found a new-pin binding bypass and a coverage counter name mismatch; both were corrected with regression coverage. +- Live public metadata smoke passed with zero inference requests. Retrieval is separate from model dispatch and entitlement evidence. +- Local candidate SHA-256: `668d3b8ab17c35abdaf3e3069e8dba0b177c381257b01408262a0049707c565f`. Exact packaged acceptance is running separately; this is candidate evidence, not final-release bytes or a publication claim. + +The preexisting untracked Grok plan is preserved. Merge, final artifact creation, publication, and public verification remain separate release gates. diff --git a/docs/plans/2026-09-08-working-tree-preserved.patch b/docs/plans/2026-09-08-working-tree-preserved.patch new file mode 100644 index 0000000..cf8c520 --- /dev/null +++ b/docs/plans/2026-09-08-working-tree-preserved.patch @@ -0,0 +1,82 @@ +diff --git a/src/core/pricing.js b/src/core/pricing.js +index 7287e98..c4b862b 100644 +--- a/src/core/pricing.js ++++ b/src/core/pricing.js +@@ -284,8 +284,20 @@ export function resolveModelEvidence(live, snapshot) { + const record = snapshot?.models?.[live.id]; + if (!record) return unknownPricing("model-not-in-public-source"); + const api = normalizeApiIdentity(live.api); ++ // Identity must match on provider/model id and npm adapter. ++ // For the endpoint URL: only treat as conflict if the public source ++ // (models.dev) declared a specific URL and the live one differs or is absent. ++ // Many providers in models.dev omit the api URL (null); a valid live URL ++ // from OpenCode should not be rejected in that case. ++ const publicUrl = record.api.url; ++ const liveUrl = api.url; ++ const urlConflict = ++ publicUrl != null && ++ (liveUrl == null || liveUrl !== publicUrl); + const conflict = +- ["id", "npm", "url"].some((key) => api[key] !== record.api[key]) || ++ api.id !== record.api.id || ++ api.npm !== record.api.npm || ++ urlConflict || + !api.id || + !api.npm || + !api.urlValid || +diff --git a/src/server/opencode-cli.js b/src/server/opencode-cli.js +index 54abdcf..4dcdfc9 100644 +--- a/src/server/opencode-cli.js ++++ b/src/server/opencode-cli.js +@@ -417,23 +417,37 @@ export function mergeDiscoveredCatalog( + reasons: ["identity-or-rate-conflict"], + }; + } else pricing = unknownPricing(); +- // Compatibility only: complete positive CLI rates can establish reported +- // paid status when no independent source record contradicts them. ++ // Compatibility only: complete positive CLI rates can establish reported-paid ++ // when there is no independent source record, or when the public record ++ // could not be used due to an identity conflict (e.g. models.dev omitted ++ // the api URL but OpenCode reported a valid one + positive per-token costs). ++ // This lets newly-added or provider-specific paid models become usable ++ // without relaxing the "complete positive rates + urlValid" requirement. ++ // Clean (non-conflicting) public evidence from models.dev is still preferred. ++ const publicHasRecord = !!(publicMetadata && publicMetadata.models?.[id]); ++ const conflictedWithPublic = pricing?.reasons?.includes("identity-conflict"); + if ( +- (!publicMetadata || !publicMetadata.models?.[id]) && +- pricing.source !== "https://models.dev/api.json" && + reported.class === "paid" && +- api.urlValid ++ api.urlValid && ++ (!publicHasRecord || conflictedWithPublic) + ) { +- pricing = { +- ...reported, +- source: "reported-paid", +- digest: null, +- fetchedAt: observedAt, +- expiresAt: new Date( +- Date.parse(observedAt) + PRICING_TTL_MS, +- ).toISOString(), +- }; ++ // Only override if we do not already have a clean paid record from the ++ // independent public source. ++ if ( ++ !pricing || ++ pricing.class !== "paid" || ++ pricing.source === "https://models.dev/api.json" && (pricing.reasons?.length ?? 0) > 0 ++ ) { ++ pricing = { ++ ...reported, ++ source: "reported-paid", ++ digest: null, ++ fetchedAt: observedAt, ++ expiresAt: new Date( ++ Date.parse(observedAt) + PRICING_TTL_MS, ++ ).toISOString(), ++ }; ++ } + } + const pricingClass = classifyPricingEvidence(pricing, { now }); + // A fresh observation resolves the rejection only when its resulting diff --git a/docs/releasing.md b/docs/releasing.md index 0f78b03..bc9bcca 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -8,7 +8,7 @@ This maintainer procedure does not assert publication. The canonical repository - Update `package.json`, the root lockfile versions, changelog and behavior/security/support docs. Keep historical evidence dated; do not invent a final digest or publication link. - Independently review the whole change against the approved specification, code and security boundaries. Resolve material findings before merge. Preserve DCO sign-offs on every commit. - Merge through the reviewed PR and protected main, without bypassing required checks. Required contexts remain `verify (22.12.0)` and `verify (24.x)`; also wait for **all** pack and Linux/macOS acceptance jobs and applicable security checks, even if they are not branch-required contexts. -- Keep model ranking, benchmark campaigns, provider authentication integrations, new platform claims, and automatic OpenCode instance disposal outside 0.3.0 scope. +- Keep model ranking, benchmark campaigns, provider authentication integrations, new platform claims, and automatic OpenCode instance disposal outside 0.4.0 scope. ## 2. Source and package-content checks @@ -26,7 +26,9 @@ The dry run skips `prepack` only after the explicit full verification/build abov For source host work, `OMC_HOST_BINARY=/absolute/opencode npm run test:host` requires exact OpenCode 1.18.22 or 1.18.28 and reports `checkout-source`. This does not replace installed-tarball acceptance. -## 3. Build one final tarball from clean protected main +## 3. Prepare candidates, then build one final tarball from clean protected main + +During PR preparation, use `OMC_ARTIFACT_STAGE=candidate node scripts/pack-artifact.mjs /absolute/candidate-artifact` after verification/build. Candidate evidence is review evidence only. Do not create final-mode bytes, publish, or claim the 0.4.0 platform matrix passed before the corresponding gates execute. After merge, dispatch `.github/workflows/ci.yml` on `main` with `artifact-stage: final`. It verifies source, builds, packs once, and sends the same `omc-final-tarball` artifact to all acceptance jobs. Final mode rejects a dirty source tree or another CI ref. An authorized equivalent local pack from the exact clean protected-main commit is: @@ -50,22 +52,24 @@ OMC_HOST_BINARY_122=/absolute/opencode-1.18.22 \ OMC_HOST_BINARY_128=/absolute/opencode-1.18.28 \ OMC_EXPECTED_SHA256='' \ OMC_EVIDENCE_PATH=/absolute/evidence/package.json \ -npm run test:package -- /absolute/final-artifact/opencode-model-control-0.3.0.tgz +npm run test:package -- /absolute/final-artifact/opencode-model-control-0.4.0.tgz ``` +The harness retrieves only the fixed public npm 0.3.0 metadata and tarball, requires SHA-256 `26a532b44c96d643c0543a78d2fef1ab2c1a3b83886e6715cef0ab683d3413ab` and matching registry SHA-512 integrity before installing the baseline, and records its provenance separately. Retrieval failure or mismatch fails acceptance; it must never substitute another version or continue with unverified bytes. These requests are artifact/metadata traffic, not provider inference. + `OMC_BROWSER_EXECUTABLE` can select an existing compatible Chromium executable; otherwise the pinned Playwright browser must be installed. Missing binaries or incomplete tests fail the gate. The package command installs the exact tgz and requires: -- All 16 package checks, including current install, actual checked-in 0.2.1 upgrade, private byte-exact v2-to-v3 migration, stable surface 2, MCP, normal production startup without Vite, read-only 403, authorized refresh, restart token rotation, Connect/Disconnect and exact config restoration. -- Both actual hosts executing the installed package core/service/plugin: 19 scenarios and 56 synthetic loopback requests each. All owned roles switch A → B live without config writes/restart. Missing C, unknown pricing, disables, saved availability, API conflicts and mid-dispatch revocation block before inference; both media lanes, ordinary resumes, synchronous/background repair and revocation, unrelated agents, owned slash summary and changed-parent-pin behavior pass. -- All 12 real browser interactions against the installed compiled `dist` with no Vite: zero failures, skips or flakes, plus hashes of the served HTML/JS/CSS. -- Digest-bound evidence identifying installed-tarball target, OS kernel/architecture, Node and host versions, **112 loopback synthetic requests and zero real-provider inference**. Keep all prompts, attachments, credentials and private panel launch URLs out of logs/evidence. +- All package checks, including current install, exact public 0.3.0 upgrades for Free and Paid, retained checked-in 0.2.1 coverage, private byte-exact v3-to-v4 and v2-to-v4 backups, managed surface 3, MCP, normal production startup without Vite, read-only 403, authorized refresh, restart token rotation, Connect/Disconnect and exact config restoration. Migration must preserve verified-pricing semantics, disabled models, absent-model pins, and auto-inclusion. Old surface status must report Update required without changing config or receipt; explicitly update, restart, disconnect, restart, and verify reconnect recovery. +- Both actual hosts executing the installed package core/service/plugin: the complete current scenario set with synthetic loopback requests. Record the executed scenario and request counts in evidence. All owned roles switch A → B live without config writes/restart. Missing C, unknown pricing, disables, saved availability, API conflicts and mid-dispatch revocation block before inference; both media lanes, ordinary resumes, synchronous/background repair and revocation, unrelated agents, owned slash summary and changed-parent-pin behavior pass. +- All current real browser interactions against the installed compiled `dist` with no Vite: zero failures, skips or flakes, plus hashes of the served HTML/JS/CSS. +- Digest-bound evidence identifying installed-tarball target, OS kernel/architecture, Node and host versions, **the actual loopback synthetic request count and zero real-provider inference**. Keep all prompts, attachments, credentials and private panel launch URLs out of logs/evidence. Run the negative artifact binding proof once (CI does this on Linux/Node22): ```sh OMC_HOST_BINARY=/absolute/opencode-1.18.28 \ OMC_EVIDENCE_PATH=/absolute/evidence/artifact-guards.json \ -node scripts/artifact-guards.mjs /absolute/final-artifact/opencode-model-control-0.3.0.tgz +node scripts/artifact-guards.mjs /absolute/final-artifact/opencode-model-control-0.4.0.tgz ``` This must reject both a deliberately broken plugin tarball and a deliberately broken UI tarball while healthy checkout source is present. It proves acceptance cannot silently fall back to checkout code. @@ -82,9 +86,9 @@ That command retrieves public Models.dev metadata without inference. Neither it - Confirm npm authorization and package ownership. If login or CI authorization is unavailable, finish unaffected work and report the exact external gate. Do not infer success or use a different artifact to work around it. - Confirm GitHub immutable releases are enabled and protect the exact version tag from force updates/deletion **before** creating it. The setting is not retroactive. -- Create a draft GitHub release for `v0.3.0` at the exact reviewed protected-main commit. Stage the final tested tarball, `SHA256SUMS`, pack evidence and redacted acceptance results. Verify draft target, notes, filenames and downloaded digest before publication. -- Publish the exact tested file to npm using the authorized registry flow. The command, only after all gates and authorization, is `npm publish /absolute/final-artifact/opencode-model-control-0.3.0.tgz --access public`. Never publish from the checkout or rebuild for npm. -- Retrieve the exact npm 0.3.0 public tarball, verify registry integrity and its SHA-256 against the final file, and test its installed experience in isolation. +- Create a draft GitHub release for `v0.4.0` at the exact reviewed protected-main commit. Stage the final tested tarball, `SHA256SUMS`, pack evidence and redacted acceptance results. Verify draft target, notes, filenames and downloaded digest before publication. +- Publish the exact tested file to npm using the authorized registry flow. The command, only after all gates and authorization, is `npm publish /absolute/final-artifact/opencode-model-control-0.4.0.tgz --access public`. Never publish from the checkout or rebuild for npm. +- Retrieve the exact npm 0.4.0 public tarball, verify registry integrity and its SHA-256 against the final file, and test its installed experience in isolation. - Publish the finalized GitHub draft when all notes/assets are final, then verify the release is immutable and its public downloaded tarball matches the same SHA-256. - Never replace a published asset, move/reuse a published tag, or delete/recreate the release. Corrections require a new version and artifact. @@ -92,6 +96,6 @@ That command retrieves public Models.dev metadata without inference. Neither it Fetch each channel's public tarball into a separate directory and run the same exact-package acceptance command against each retrieved file. Preserve channel URL, retrieval time, digest/integrity and redacted results. Explicitly compare **both** public downloads with the original tested final bytes. -After confirmed publication, a clean user install can use `npm install --prefix /absolute/disposable-install opencode-model-control@0.3.0`. Verify its CLI version and public installed startup/refresh/Connect/restart/status/Disconnect/restart path; never change a maintainer's everyday global install as this check. README `@latest` commands resolve the registry's published channel, while this release check stays pinned. +After confirmed publication, a clean user install can use `npm install --prefix /absolute/disposable-install opencode-model-control@0.4.0`. Verify its CLI version and public installed startup/refresh/Connect/restart/status/Disconnect/restart path; never change a maintainer's everyday global install as this check. README `@latest` commands resolve the registry's published channel, while this release check stays pinned. -Only after final artifact/public installation verification may the maintainer describe 0.3.0 as published and close [issue #12: Verify pricing for opencode/muse-spark-1.3-contributor-free](https://github.com/BitL8-ByteShort/opencode-model-control/issues/12). Keep the issue open while publication is blocked; do not use automatic issue-closing PR wording before this gate. Historical candidate results remain historical and do not become final-byte evidence. +Only after final artifact/public installation verification may the maintainer describe 0.4.0 as published or close release-blocked issues. Do not use automatic issue-closing PR wording before that gate. Historical candidate results remain historical and do not become final-byte evidence. diff --git a/package-lock.json b/package-lock.json index 79212c1..e4468a0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-model-control", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-model-control", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/server": "2.0.0", diff --git a/package.json b/package.json index addab17..c75ac67 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "opencode-model-control", - "version": "0.3.0", + "version": "0.4.0", "description": "A local model routing control panel and MCP companion for OpenCode.", "keywords": [ "opencode", diff --git a/scripts/browser/panel.spec.mjs b/scripts/browser/panel.spec.mjs index f58c4a0..f6234ac 100644 --- a/scripts/browser/panel.spec.mjs +++ b/scripts/browser/panel.spec.mjs @@ -8,6 +8,7 @@ import { capabilityDetailsSchema, pricingSchema, } from "../../src/core/catalog-evidence.js"; +let usageFixture = null; const token = randomBytes(32).toString("base64url"); const clone = structuredClone; const capabilities = { @@ -78,9 +79,11 @@ const model = (id, pricingClass = "free") => ({ }); function initial() { return { - schemaVersion: 3, + schemaVersion: 4, settingsRevision: "s1", catalogRevision: "c1", + connectionRevision: "conn1", + connections: ["fixture", "other"].map(providerId => ({id: `private-${providerId}`, providerId, bindingRevision: "binding1", authKind: "api-key", billing: {kind: "unknown", source: "unknown", observedAt: null}, transportVisibility: "host-managed", inventoryObservedAt: "2026-09-08T12:00:00Z", entitlement: "not-reported", quota: null})), blockedRoles: {}, catalog: [ model("fixture/Alpha"), @@ -88,9 +91,10 @@ function initial() { { ...model("fixture/Blocked", "unknown"), available: false }, ], settings: { - schemaVersion: 3, + schemaVersion: 4, costPolicy: "free-only", costPreference: "free-first", + paidEligibility: "verified-pricing", autoIncludeNewModels: true, makeRouterDefault: true, maxDelegationDepth: 1, @@ -208,12 +212,12 @@ test.beforeAll(async () => { }, 409, ); - if (input.settings?.schemaVersion !== 3) + if (input.settings?.schemaVersion !== 4) return send( { error: { code: "INVALID_SETTINGS", - message: "Canonical settings v3 required.", + message: "Canonical settings v4 required.", }, }, 400, @@ -252,7 +256,7 @@ test.beforeAll(async () => { return send({ roles: [], caveats: [], status: "unverified" }); if (path === "/api/runtime-qualification") return send({ results: [], boundaries: [], running: false }); - if (path === "/api/usage") return send(null); + if (path === "/api/usage") return send(usageFixture); return send({ error: { message: "Unexpected fixture API action" } }, 404); }; if (process.env.OMC_PACKAGE_ROOT) { @@ -337,6 +341,7 @@ test.afterAll(async () => { }); test.beforeEach(async () => { state = initial(); + usageFixture = null; requests = []; refreshGate = saveGate = getGate = null; saveStarted = null; @@ -441,7 +446,7 @@ test("policy enrollment, blocked off switch and pins remain explicit across Free await expect( page.getByRole("combobox", { name: "Reviewer", exact: false }), ).toHaveValue("fixture/Blocked"); - await expect(blocked).toContainText("pricing"); + await expect(blocked).toContainText("verified free"); await enroll(page).uncheck(); const alpha = row(page, "Alpha"); await expect(alpha.getByRole("checkbox")).not.toBeChecked(); @@ -869,3 +874,57 @@ test("delayed stale-return observation cannot start refresh after a newer Save o requests.filter((request) => request.path === "/api/catalog/refresh"), ).toEqual([]); }); + + +test("provider-independent billing declaration survives refresh, binds role, and saves baseline revision", async ({page}) => { + const errors = await open(page); + await page.getByRole("radio", {name: "Paid", exact: true}).click(); + const declaration = page.getByRole("combobox", {name: "Declare billing for fixture", exact: true}); + await declaration.focus(); + await declaration.selectOption("subscription"); + await expect(page.locator("#billing-connections")).toContainText("Declared by you"); + refreshGate = gate(); + await refresh(page).click(); + state.catalog.push(model("fixture/Subscription model", "unknown")); + refreshGate.release(); + await expect(declaration).toHaveValue("subscription"); + await page.getByRole("combobox", {name:"Primary orchestrator",exact:true}).selectOption("fixture/Subscription model"); + await save(page).click(); + await expect(save(page)).toBeDisabled(); + expect(saveStarted.expectedConnectionRevision).toBe("conn1"); + expect(state.settings.billingDeclarations["private-fixture"]).toMatchObject({kind:"subscription",source:"user-declared",bindingRevision:"binding1"}); + expect(state.settings.roleConnections.orchestrator).toEqual({connectionId:"private-fixture",bindingRevision:"binding1"}); + state.connections[0].bindingRevision="binding2"; + await refresh(page).click(); + await expect(page.getByText(/Connection changed — previous declaration/)).toBeVisible(); + await expect(page.getByRole("combobox",{name:"Primary orchestrator",exact:true})).toHaveValue("fixture/Subscription model"); + await page.getByRole("button",{name:"Use current connection for Primary orchestrator",exact:true}).click(); + await save(page).click(); + await expect(save(page)).toBeDisabled(); + expect(state.settings.roleConnections.orchestrator.bindingRevision).toBe("binding2"); + await page.setViewportSize({width:390,height:844}); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + expect(await page.locator("body").innerText()).not.toContain("private-fixture"); + expect(errors).toEqual([]); +}); + +test("historical captured billing separates currencies and unknown costs on mobile", async ({page}) => { + const tokens={input:0,output:null,reasoning:null,cacheRead:null,cacheWrite:null,total:null}; + const observation={eventKey:"private-event",observedAt:"2026-09-08T12:00:00Z",connectionId:"old-private-slot",bindingRevision:"old-binding",billingKind:"subscription",billingSource:"user-declared",tokens,recordedCost:{amount:0,currency:"USD"},priceSnapshotId:null}; + usageFixture={schemaVersion:2,source:"opencode-local-accounting",accounting:"opencode-recorded",window:"30d",windowDays:30,generatedAt:"2026-09-08T12:00:00Z",totals:{sessions:1,messages:3,costUsd:null,tokens},byModel:[],diagnostics:{modelsSeen:0,modelsReturned:0,modelsTruncated:false,unattributedMessages:0,zeroTokenMessages:0,earliestMessageAt:null,latestMessageAt:null},attributed:{observations:[observation,{...observation,eventKey:"other",recordedCost:{amount:2,currency:"EUR"}},{...observation,eventKey:"third",billingKind:"metered-api",bindingRevision:"new-binding",recordedCost:null}],coverage:{firstObservedAt:"2026-09-08T12:00:00Z",droppedCount:2,truncated:true,pendingCount:1,failedWriteCount:3,partial:true,lastFailureCode:"ATTRIBUTION_WRITE_FAILED"}},caveats:[]}; + await open(page); + const captured=page.getByRole("region",{name:"Captured connection usage"}); + await expect(captured).toContainText("Partial coverage"); + await expect(captured.locator("article")).toHaveCount(3); + await expect(captured).toContainText("0 USD"); + await expect(captured).toContainText("2 EUR"); + await expect(captured).toContainText("Recorded cost: Not reported"); + await expect(captured).toContainText("Output: Not reported"); + await expect(captured).toContainText("Retention truncated"); + await expect(captured).toContainText("Failed writes: 3"); + await expect(captured).toContainText("Some captured usage could not be saved"); + await page.setViewportSize({width:390,height:844}); + await captured.scrollIntoViewIfNeeded(); + expect(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth)).toBe(true); + expect(await page.locator("body").innerText()).not.toContain("old-private-slot"); +}); diff --git a/scripts/host-acceptance.mjs b/scripts/host-acceptance.mjs index 9a709da..d5f9b18 100644 --- a/scripts/host-acceptance.mjs +++ b/scripts/host-acceptance.mjs @@ -78,6 +78,11 @@ const fake = createServer(async (req, res) => { ["a", "b"].includes(body.model), `Invalid provider model ${body.model}`, ); + assert.equal( + req.headers["x-omc-fixture-transport"], + "subscription", + "omctest auth-loader fetch must handle the provider request", + ); evidence.requests.push({ scenario: active, path: req.url, @@ -170,6 +175,8 @@ try { }; await mkdir(join(root, "policy")); await mkdir(join(root, "project")); + settings.costPolicy = "known-cost"; + settings.paidEligibility = "verified-pricing"; await pin("a"); const hm = (id) => ({ id, @@ -183,6 +190,38 @@ try { }); // A fixture-only second plugin changes ordinary saved files between the real // OMC chat.message selection and chat.params revalidation. It grants nothing. + const fetchMarker = join(root, "subscription-fetch-used"); + const subscriptionPlugin = join(root, "subscription-auth.mjs"); + await writeFile( + subscriptionPlugin, + `import { appendFile } from "node:fs/promises"; +const inner = globalThis.fetch.bind(globalThis); +async function subscriptionFetch(input, init = {}) { + await appendFile(${JSON.stringify(fetchMarker)}, "1"); + if (input instanceof Request) { + const headers = new Headers(input.headers); + headers.set("x-omc-fixture-transport", "subscription"); + return inner(new Request(input, { headers })); + } + const headers = new Headers(init.headers); + headers.set("x-omc-fixture-transport", "subscription"); + return inner(input, { ...init, headers }); +} +export default async () => ({ + auth: { + provider: "omctest", + async loader(getAuth) { + const auth = await getAuth(); + if (auth?.type !== "oauth") return {}; + return { + apiKey: "opencode-oauth-dummy-key", + fetch: subscriptionFetch, + }; + }, + }, +}); +`, + ); const interleavePlugin = join(root, "interleave.mjs"); await writeFile( interleavePlugin, @@ -208,12 +247,13 @@ export default async () => ({ "chat.message": async (_input, output) => { npm: "@ai-sdk/openai-compatible", name: "Fixture", env: [], - options: { baseURL: endpoint, apiKey: "fixture" }, + options: { baseURL: endpoint }, models: { a: hm("a"), b: hm("b") }, }, }, agent: buildOpenCodeConfig().agent, plugin: [ + pathToFileURL(subscriptionPlugin).href, pathToFileURL(join(targetRoot, "src/opencode/plugin.js")).href, pathToFileURL(interleavePlugin).href, ], @@ -231,6 +271,20 @@ export default async () => ({ "chat.message": async (_input, output) => { const configBytes = JSON.stringify(config); await writeFile(configPath, configBytes); await writeFile(join(root, "models.json"), "{}"); + const auth = { + omctest: { + type: "oauth", + access: "fixture-access-token", + refresh: "fixture-refresh-token", + expires: Date.now() + 86400000, + }, + }; + await mkdir(join(root, "data/opencode"), { recursive: true }); + await writeFile( + join(root, "data/opencode/auth.json"), + JSON.stringify(auth), + { mode: 0o600 }, + ); const env = { PATH: process.env.PATH, HOME: root, @@ -247,7 +301,7 @@ export default async () => ({ "chat.message": async (_input, output) => { OPENCODE_MODELS_PATH: join(root, "models.json"), OPENCODE_DISABLE_SHARE: "1", OPENCODE_EXPERIMENTAL_BACKGROUND_SUBAGENTS: "true", - OPENCODE_AUTH_CONTENT: "{}", + OPENCODE_AUTH_CONTENT: JSON.stringify(auth), XDG_CONFIG_HOME: join(root, "config"), XDG_CACHE_HOME: join(root, "cache"), XDG_DATA_HOME: join(root, "data"), @@ -424,9 +478,17 @@ export default async () => ({ "chat.message": async (_input, output) => { ...state.settings.roleAssignments, "code-worker": "omctest/c", }, + roleConnections: { + ...state.settings.roleConnections, + "code-worker": { + connectionId: state.connections.find(connection => connection.providerId === "omctest").id, + bindingRevision: state.connections.find(connection => connection.providerId === "omctest").bindingRevision, + }, + }, }, { expectedSettingsRevision: state.settingsRevision, + expectedConnectionRevision: state.connectionRevision, catalogRevision: state.catalogRevision, }, ); @@ -822,6 +884,18 @@ export default async () => ({ "chat.message": async (_input, output) => { assert.equal(evidence.requests.length - start, 1); handler = async () => ({}); }); + await check("subscription-shaped-provider-fetch", async () => { + settings.costPolicy = "known-cost"; + settings.paidEligibility = "configured-connections"; + await pin("b"); + const start = evidence.requests.length; + await turn(await session(), "omc-code-worker", "SUBSCRIPTION_FETCH"); + assert.ok(evidence.requests.length > start); + assert.match(await readFile(fetchMarker, "utf8"), /1/); + settings.costPolicy = "free-only"; + settings.paidEligibility = "verified-pricing"; + await pin("a"); + }); evidence.passed = true; } finally { eventController?.abort(); diff --git a/scripts/package-acceptance.mjs b/scripts/package-acceptance.mjs index 8b9d099..301169a 100644 --- a/scripts/package-acceptance.mjs +++ b/scripts/package-acceptance.mjs @@ -206,7 +206,7 @@ try { assert.equal(proof.tarballSha256, sha256); assert.equal(proof.host, host.version); assert.equal(proof.passed, true); - assert.equal(proof.scenarios.length, 19); + assert.ok(proof.scenarios.length >= 20); evidence.mockedProviderRequests += proof.requests.length; evidence.hostAcceptance.push({ host: proof.host, @@ -277,7 +277,7 @@ try { 0, "Installed production UI browser scenarios failed; see redacted browser evidence", ); - assert.equal(report.stats.expected, 12); + assert.ok(report.stats.expected >= 14); assert.equal(report.stats.unexpected, 0); assert.equal(report.stats.skipped, 0); assert.equal(report.stats.flaky, 0); @@ -306,83 +306,221 @@ try { assert.equal((await integrate("disconnect")).installed, false); assert.equal(await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"), original); evidence.checks.push("fresh-current-connect-status-disconnect"); - // Upgrade starts from the old version's own state, never a downgrade of - // the newer catalog that the independent fresh-install fixture just wrote. - await rename(env.OMC_CONFIG_DIR, join(root, "fresh-install-policy")); - await mkdir(env.OMC_CONFIG_DIR); - // An actual 0.2.1 install creates its own managed surface and receipt. - const prior = resolve("packages/opencode-model-control-0.2.1.tgz"); - assert.equal( - createHash("sha256") - .update(await readFile(prior)) - .digest("hex"), - "b0c0e161bec91ac384d12336d9786aa41870a65d3a291a72760a1e84fb3a489c", - ); - const oldPrefix = join(root, "prior"); - await run( - "npm", - [ - "install", - "--prefix", - oldPrefix, - "--omit=dev", - "--no-audit", - "--no-fund", - prior, - ], - { NODE_OPTIONS: "" }, - ); - const oldCore = await import( - pathToFileURL( - join(oldPrefix, "node_modules/opencode-model-control/src/core/index.js"), - ) - ); - const legacySettings = oldCore.createDefaultSettings(); - assert.equal(legacySettings.schemaVersion, 2); - await writeFile( - join(env.OMC_CONFIG_DIR, "settings.json"), - JSON.stringify(legacySettings), - { mode: 0o600 }, + // Fetch only the fixed public baseline. Verify both registry integrity and the + // independently recorded release digest before npm can install any bytes. + const baselineUrl = + "https://registry.npmjs.org/opencode-model-control/-/opencode-model-control-0.3.0.tgz"; + const baselineSha256 = + "26a532b44c96d643c0543a78d2fef1ab2c1a3b83886e6715cef0ab683d3413ab"; + const metadataResponse = await fetch( + "https://registry.npmjs.org/opencode-model-control/0.3.0", + { redirect: "error", signal: AbortSignal.timeout(30000) }, ); + assert.equal(metadataResponse.status, 200); + const metadata = await metadataResponse.json(); + assert.equal(metadata.version, "0.3.0"); + assert.equal(metadata.dist.tarball, baselineUrl); + const baselineResponse = await fetch(baselineUrl, { + redirect: "error", + signal: AbortSignal.timeout(30000), + }); + assert.equal(baselineResponse.status, 200); + const baselineBytes = Buffer.from(await baselineResponse.arrayBuffer()); assert.equal( - ( - await integrate( - "connect", - join( - oldPrefix, - "node_modules/opencode-model-control/bin/opencode-model-control.js", - ), - ) - ).installed, - true, - ); - const oldReceipt = JSON.parse( - await readFile( - join(env.OMC_CONFIG_DIR, "opencode-integration.json"), - "utf8", - ), + createHash("sha256").update(baselineBytes).digest("hex"), + baselineSha256, ); - assert.equal(oldReceipt.managedSurfaceVersion, 1); - const legacySaved = await readFile( - join(env.OMC_CONFIG_DIR, "settings.json"), - "utf8", - ); - assert.equal(JSON.parse(legacySaved).schemaVersion, 2); - assert.equal((await integrate("status")).code, "UPDATE_REQUIRED"); - assert.equal((await integrate("connect")).installed, true); - assert.equal((await integrate("status")).healthy, true); - const installedConfig = await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"); - assert.match(installedConfig, /Preserve this user-owned fixture comment/); - const receipt = JSON.parse( - await readFile( - join(env.OMC_CONFIG_DIR, "opencode-integration.json"), - "utf8", - ), + assert.equal( + `sha512-${createHash("sha512").update(baselineBytes).digest("base64")}`, + metadata.dist.integrity, ); - assert.equal(receipt.managedSurfaceVersion, 2); + const publicBaseline = join(root, "opencode-model-control-0.3.0.tgz"); + await writeFile(publicBaseline, baselineBytes, { mode: 0o600 }); + evidence.upgradeBaseline = { + version: "0.3.0", + url: baselineUrl, + sha256: baselineSha256, + integrity: metadata.dist.integrity, + retrievedAt: new Date().toISOString(), + traffic: "public npm artifact and metadata retrieval; no inference", + }; + const installedBaselines = new Map(); + for (const scenario of [ + { + version: "0.2.1", + schema: 2, + surface: 1, + paid: false, + autoInclude: true, + tarball: join(sourceRoot, "packages/opencode-model-control-0.2.1.tgz"), + digest: + "b0c0e161bec91ac384d12336d9786aa41870a65d3a291a72760a1e84fb3a489c", + }, + { + version: "0.3.0", + schema: 3, + surface: 2, + paid: false, + autoInclude: true, + tarball: publicBaseline, + digest: baselineSha256, + }, + { + version: "0.3.0", + schema: 3, + surface: 2, + paid: true, + autoInclude: false, + tarball: publicBaseline, + digest: baselineSha256, + }, + ]) { + const label = `${scenario.version}-${scenario.paid ? "paid" : "free"}`; + // Every upgrade starts from its old package's own catalog and receipt. + await rename(env.OMC_CONFIG_DIR, join(root, `policy-before-${label}`)); + await mkdir(env.OMC_CONFIG_DIR); + assert.equal( + createHash("sha256") + .update(await readFile(scenario.tarball)) + .digest("hex"), + scenario.digest, + ); + let oldInstalled = installedBaselines.get(scenario.version); + if (!oldInstalled) { + const oldPrefix = join(root, `prior-${scenario.version}`); + await run( + "npm", + [ + "install", + "--prefix", + oldPrefix, + "--omit=dev", + "--no-audit", + "--no-fund", + scenario.tarball, + ], + { NODE_OPTIONS: "" }, + ); + oldInstalled = join(oldPrefix, "node_modules/opencode-model-control"); + assert.equal( + JSON.parse(await readFile(join(oldInstalled, "package.json"), "utf8")) + .version, + scenario.version, + ); + installedBaselines.set(scenario.version, oldInstalled); + } + const oldCore = await import( + pathToFileURL(join(oldInstalled, "src/core/index.js")) + ); + const legacySettings = JSON.parse( + JSON.stringify(oldCore.createDefaultSettings()), + ); + assert.equal(legacySettings.schemaVersion, scenario.schema); + legacySettings.costPolicy = scenario.paid ? "known-cost" : "free-only"; + legacySettings.costPreference = scenario.paid ? "paid-first" : "free-first"; + if (scenario.schema === 3) + legacySettings.autoIncludeNewModels = scenario.autoInclude; + if (scenario.schema === 3) legacySettings.roleAssignments.reviewer = "absent/explicit-pin"; + // 0.2.1 rejects unknown identities. Keep its fixture valid; 0.3.0 must also + // preserve absent disabled identities and requested pins. + const disabledId = scenario.schema === 3 ? "absent/disabled-model" : Object.entries(legacySettings.modelControls).find(([id, control]) => control.enabled && !Object.values(legacySettings.roleAssignments).includes(id))?.[0]; + assert.ok(disabledId, `${label}: baseline must expose a model to disable`); + legacySettings.modelControls[disabledId] = + scenario.schema === 3 + ? { selection: "disabled", available: false } + : { enabled: false, available: false }; + assert.deepEqual(oldCore.validateSettings(legacySettings).modelControls[disabledId], legacySettings.modelControls[disabledId]); + const settingsPath = join(env.OMC_CONFIG_DIR, "settings.json"); + await writeFile( + settingsPath, + JSON.stringify(legacySettings, null, 2) + "\n", + { mode: 0o600 }, + ); + assert.equal( + ( + await integrate( + "connect", + join(oldInstalled, "bin/opencode-model-control.js"), + ) + ).installed, + true, + ); + const receiptPath = join(env.OMC_CONFIG_DIR, "opencode-integration.json"); + const oldReceipt = await readFile(receiptPath, "utf8"); + assert.equal( + JSON.parse(oldReceipt).managedSurfaceVersion, + scenario.surface, + ); + const legacySaved = await readFile(settingsPath, "utf8"); + const savedDisabled = JSON.parse(legacySaved).modelControls[disabledId]; + if (scenario.schema === 3) assert.deepEqual(savedDisabled, legacySettings.modelControls[disabledId]); + else assert.equal(savedDisabled?.enabled, false, `${label}: old Connect must retain the disabled choice`); + if (scenario.schema === 3) assert.equal(JSON.parse(legacySaved).roleAssignments.reviewer, "absent/explicit-pin"); + const beforeUpdate = await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"); + assert.equal((await integrate("status")).code, "UPDATE_REQUIRED"); + // Merely observing an outdated connection must never install a new surface. + assert.equal(await readFile(receiptPath, "utf8"), oldReceipt); + assert.equal( + await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"), + beforeUpdate, + ); + assert.equal((await integrate("connect")).installed, true); + assert.equal((await integrate("status")).healthy, true); + assert.match( + await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"), + /Preserve this user-owned fixture comment/, + ); + assert.equal( + JSON.parse(await readFile(receiptPath, "utf8")).managedSurfaceVersion, + 3, + ); + const migrated = JSON.parse(await readFile(settingsPath, "utf8")); + assert.equal(migrated.schemaVersion, 4); + assert.equal(migrated.paidEligibility, "verified-pricing"); + for (const key of [ + "costPolicy", + "costPreference", + "roleAssignments", + "makeRouterDefault", + "maxDelegationDepth", + "maxFallbacksPerAssignment", + ]) + assert.deepEqual( + migrated[key], + JSON.parse(legacySaved)[key], + `${label}: ${key}`, + ); + assert.equal(migrated.autoIncludeNewModels, scenario.autoInclude); + if (scenario.schema === 3) + assert.deepEqual( + migrated.modelControls, + JSON.parse(legacySaved).modelControls, + ); + assert.deepEqual(migrated.modelControls[disabledId], { + selection: "disabled", + available: savedDisabled.available, + }); + const migrations = (await readdir(env.OMC_CONFIG_DIR)).filter((name) => + name.startsWith(`settings.json.v${scenario.schema}.backup-`), + ); + assert.equal(migrations.length, 1); + const backupPath = join(env.OMC_CONFIG_DIR, migrations[0]); + assert.equal(await readFile(backupPath, "utf8"), legacySaved); + assert.equal((await stat(backupPath)).mode & 0o777, 0o600); + evidence.checks.push( + `actual-${label}-policy-preserving-v${scenario.schema}-v4-private-exact-backup`, + ); + // Retain the final Paid fixture for real-host restart and recovery below. + if (!scenario.paid) { + assert.equal((await integrate("disconnect")).installed, false); + assert.equal( + await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"), + original, + ); + } + } evidence.checks.push( - "actual-0.2.1-managed-surface-upgrade", - "connection-update-status", + "connection-update-status-without-config-or-receipt-write", "config-comment-preserved", ); const hostBinary = hostMatrix[1].binary; @@ -516,24 +654,7 @@ try { 0o777, 0o600, ); - const migrations = (await readdir(env.OMC_CONFIG_DIR)).filter((n) => - n.startsWith("settings.json.v2.backup-"), - ); - assert.equal(migrations.length, 1); - assert.equal( - await readFile(join(env.OMC_CONFIG_DIR, migrations[0]), "utf8"), - legacySaved, - ); - assert.equal( - JSON.parse( - await readFile(join(env.OMC_CONFIG_DIR, "settings.json"), "utf8"), - ).schemaVersion, - 3, - ); - evidence.checks.push( - "private-settings-receipt-and-config-backups", - "actual-v2-v3-migration-and-private-exact-backup", - ); + evidence.checks.push("private-settings-receipt-and-config-backups"); assert.equal((await integrate("disconnect")).installed, false); assert.equal(await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"), original); await restartHost(false); @@ -542,6 +663,17 @@ try { "disconnect-restores-exact-config", "actual-host-disconnected-restart-status", ); + // Recover through the normal guarded connector, never by copying an old + // full-config backup over unrelated user changes. + assert.equal((await integrate("connect")).installed, true); + await restartHost(true); + assert.equal((await integrate("status")).healthy, true); + assert.equal((await integrate("disconnect")).installed, false); + await restartHost(false); + assert.equal(await readFile(env.OMC_OPENCODE_CONFIG_PATH, "utf8"), original); + evidence.checks.push( + "post-upgrade-disconnect-reconnect-recovery-with-explicit-restarts", + ); evidence.passed = true; } finally { for (const child of children) await stop({ child }); diff --git a/src/core/catalog.js b/src/core/catalog.js index dbb8ade..8a29999 100644 --- a/src/core/catalog.js +++ b/src/core/catalog.js @@ -1,4 +1,5 @@ import { classifyPricingEvidence, unknownPricing, normalizeApiIdentity, capabilityDetails, digestJson } from "./pricing.js"; +import { resolveEligibility } from "./eligibility.js"; import { pricingSchema, capabilityDetailsSchema } from "./catalog-evidence.js"; import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -356,6 +357,7 @@ export function eligibleModelsForRole({ role, modalities = ["text"], access = "read", + connections, }) { if (!MODEL_ROLES.includes(role)) { throw routerError("INVALID_ROLE", "The requested model role is unsupported."); @@ -379,16 +381,19 @@ export function eligibleModelsForRole({ return validateCatalog(catalog).models .filter((model) => { - const control = settings?.modelControls?.[model.id]; - const pricingClass = classifyModelPricing(model); - return ( - pricingClass !== "unknown" && - (costPolicy === "known-cost" || pricingClass === "free") && - model.available === true && - modelEnabled(settings, model.id) && - control?.available !== false && - modelSupports({ model, role, modalities, access }) - ); + const providerId = model.id.slice(0, model.id.indexOf("/")); + const connection = Array.isArray(connections) + ? connections.find((item) => item.providerId === providerId) ?? null + : null; + return resolveEligibility({ + model, + connection, + connections, + settings, + role, + modalities, + access, + }).allowed; }) .sort((left, right) => compareEligibleModels(left, right, role, costPreference), diff --git a/src/core/connections.js b/src/core/connections.js new file mode 100644 index 0000000..4ba04b7 --- /dev/null +++ b/src/core/connections.js @@ -0,0 +1,225 @@ +import { createHash } from "node:crypto"; +import { routerError } from "./errors.js"; +import { isPlainObject } from "./utils.js"; +import { normalizeApiIdentity } from "./pricing.js"; + +export const CURRENT_CONNECTION_STORE_VERSION = 1; +export const BILLING_KINDS = Object.freeze([ + "subscription", + "metered-api", + "prepaid", + "local", + "free", + "unknown", +]); +export const EVIDENCE_SOURCES = Object.freeze([ + "host", + "provider-adapter", + "user-declared", + "unknown", +]); +export const AUTH_KINDS = Object.freeze(["oauth", "api-key", "none", "unknown"]); +export const ENTITLEMENTS = Object.freeze([ + "reported-active", + "reported-revoked", + "not-reported", +]); +export const TRANSPORT_VISIBILITY = Object.freeze([ + "declared-endpoint", + "host-managed", +]); +export const QUOTA_UNITS = Object.freeze([ + "tokens", + "requests", + "credits", + "percent", +]); +export const PROVIDER_ID_PATTERN = /^[a-z0-9][a-z0-9._-]*$/i; +const UUID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const HEX32_PATTERN = /^[a-f0-9]{32}$/; +const TIMESTAMP = (value) => + typeof value === "string" && Number.isFinite(Date.parse(value)); + +function invalidConnection(message) { + throw routerError("INVALID_CONNECTION", message); +} + +export function deriveConnectionId(scopeId, providerId) { + if (typeof scopeId !== "string" || !UUID_PATTERN.test(scopeId)) + invalidConnection("Connection scope is invalid."); + if (typeof providerId !== "string" || !PROVIDER_ID_PATTERN.test(providerId)) + invalidConnection("Connection provider is invalid."); + return createHash("sha256") + .update(`omc.connection.v1\0${scopeId}\0${providerId}`) + .digest("hex") + .slice(0, 32); +} + +export function deriveBindingRevision(input) { + return createHash("sha256") + .update( + JSON.stringify({ + url: input?.url ?? null, + npm: input?.npm ?? null, + authKind: input?.authKind ?? "unknown", + billingKind: input?.billingKind ?? "unknown", + mixed: input?.mixed === true, + }), + ) + .digest("hex") + .slice(0, 32); +} + +function finiteNonNegative(value) { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +export function validateQuotaObservation(value) { + if (value === null || value === undefined) return null; + if (!isPlainObject(value)) invalidConnection("Quota observation is invalid."); + const unit = value.unit; + if (!QUOTA_UNITS.includes(unit)) invalidConnection("Quota unit is invalid."); + const numeric = ["limit", "used", "remaining"]; + const quota = { + source: + value.source === "host" || value.source === "provider-adapter" + ? value.source + : invalidConnection("Quota source is invalid."), + unit, + limit: value.limit === null ? null : value.limit, + used: value.used === null ? null : value.used, + remaining: value.remaining === null ? null : value.remaining, + resetsAt: value.resetsAt === null ? null : value.resetsAt, + observedAt: value.observedAt, + expiresAt: value.expiresAt, + }; + for (const key of numeric) { + if (quota[key] !== null && !finiteNonNegative(quota[key])) + invalidConnection("Quota values must be finite and nonnegative."); + } + if ( + unit === "percent" && + numeric.some((key) => quota[key] !== null && quota[key] > 100) + ) + invalidConnection("Quota percent values must be within 0-100."); + if (!TIMESTAMP(quota.observedAt) || !TIMESTAMP(quota.expiresAt)) + invalidConnection("Quota timestamps are invalid."); + if (!["host", "provider-adapter"].includes(quota.source)) + invalidConnection("Quota source is invalid."); + return quota; +} + +export function validateConnection(value) { + if (!isPlainObject(value)) invalidConnection("Connection is invalid."); + const providerId = value.providerId; + if (typeof providerId !== "string" || !PROVIDER_ID_PATTERN.test(providerId)) + invalidConnection("Connection provider is invalid."); + if (typeof value.id !== "string" || !HEX32_PATTERN.test(value.id)) + invalidConnection("Connection identity is invalid."); + if ( + typeof value.bindingRevision !== "string" || + !HEX32_PATTERN.test(value.bindingRevision) + ) + invalidConnection("Connection binding is invalid."); + if (!AUTH_KINDS.includes(value.authKind)) + invalidConnection("Connection authentication is invalid."); + if (!isPlainObject(value.billing)) + invalidConnection("Connection billing is invalid."); + if (!BILLING_KINDS.includes(value.billing.kind)) + invalidConnection("Connection billing is invalid."); + if (!EVIDENCE_SOURCES.includes(value.billing.source)) + invalidConnection("Connection billing is invalid."); + if (value.billing.observedAt !== null && !TIMESTAMP(value.billing.observedAt)) + invalidConnection("Connection billing is invalid."); + if (!TRANSPORT_VISIBILITY.includes(value.transportVisibility)) + invalidConnection("Connection transport is invalid."); + if (!TIMESTAMP(value.inventoryObservedAt)) + invalidConnection("Connection inventory time is invalid."); + if (!ENTITLEMENTS.includes(value.entitlement)) + invalidConnection("Connection entitlement is invalid."); + return { + id: value.id, + providerId, + bindingRevision: value.bindingRevision, + authKind: value.authKind, + billing: { + kind: value.billing.kind, + source: value.billing.source, + observedAt: value.billing.observedAt, + }, + transportVisibility: value.transportVisibility, + inventoryObservedAt: value.inventoryObservedAt, + entitlement: value.entitlement, + quota: validateQuotaObservation(value.quota ?? null), + }; +} + +export function validateConnectionSnapshot(value) { + if (!isPlainObject(value)) invalidConnection("Connection snapshot is invalid."); + if (value.schemaVersion !== CURRENT_CONNECTION_STORE_VERSION) + invalidConnection("Connection snapshot version is unsupported."); + if (typeof value.scopeId !== "string" || !UUID_PATTERN.test(value.scopeId)) + invalidConnection("Connection scope is invalid."); + if (!Array.isArray(value.connections)) + invalidConnection("Connection snapshot is invalid."); + const connections = value.connections.map(validateConnection); + const ids = new Set(); + const providers = new Set(); + for (const connection of connections) { + if (ids.has(connection.id) || providers.has(connection.providerId)) + invalidConnection("Connection identities must be unique."); + ids.add(connection.id); + providers.add(connection.providerId); + if (connection.id !== deriveConnectionId(value.scopeId, connection.providerId)) + invalidConnection("Connection identity is invalid."); + } + // Recompute from validated evidence; never accept a stale caller-supplied revision. + const revision = createHash("sha256") + .update(JSON.stringify(connections.map(({ inventoryObservedAt, quota, billing, ...binding }) => ({ + ...binding, + billing: { kind: billing.kind, source: billing.source }, + })))) + .digest("hex"); + return { + schemaVersion: CURRENT_CONNECTION_STORE_VERSION, + revision, + scopeId: value.scopeId, + connections, + }; +} + +export function applyBillingDeclarations(connections, declarations = {}) { + return connections.map((observed) => { + // Declarations belong to saved intent; removing one must not leave a cached label. + const connection = observed.billing.source === "user-declared" + ? { ...observed, billing: { kind: "unknown", source: "unknown", observedAt: null } } + : observed; + const declaration = declarations[connection.id]; + if (!declaration || declaration.bindingRevision !== connection.bindingRevision || + ["host", "provider-adapter"].includes(connection.billing.source)) return connection; + return { + ...connection, + billing: { kind: declaration.kind, source: "user-declared", + observedAt: declaration.declaredAt ?? connection.inventoryObservedAt }, + }; + }); +} + +export function connectionBindingInputs(provider) { + const models = Object.values(provider?.models ?? {}); + const identities = models.map((model) => normalizeApiIdentity(model?.api)); + const npms = [ + ...new Set(identities.map((api) => api.npm).filter((value) => value)), + ].sort(); + const urls = [ + ...new Set(identities.map((api) => api.url).filter((value) => value !== undefined)), + ].sort((left, right) => String(left) < String(right) ? -1 : 1); + const mixed = npms.length > 1 || new Set(urls.map((value) => value ?? "")).size > 1; + return { + npm: npms.length === 1 ? npms[0] : null, + url: !mixed && urls.length <= 1 ? (urls[0] ?? null) : null, + mixed, + declared: identities.some((api) => api.urlValid && api.url !== null), + }; +} diff --git a/src/core/constants.js b/src/core/constants.js index 934a6cf..3666945 100644 --- a/src/core/constants.js +++ b/src/core/constants.js @@ -33,6 +33,10 @@ export const MODALITIES = Object.freeze([ export const COST_PREFERENCES = Object.freeze(["free-first", "paid-first"]); export const COST_POLICIES = Object.freeze(["free-only", "known-cost"]); +export const PAID_ELIGIBILITY = Object.freeze([ + "verified-pricing", + "configured-connections", +]); export const PRICING_CLASSES = Object.freeze(["free", "paid", "unknown"]); export const KNOWN_MODEL_IDS = Object.freeze([ @@ -55,8 +59,9 @@ export const ROLE_REQUIREMENTS = Object.freeze({ reviewer: Object.freeze({ modalities: Object.freeze(["text"]), access: "read" }), }); -export const CURRENT_SETTINGS_VERSION = 3; +export const CURRENT_SETTINGS_VERSION = 4; export const CURRENT_CATALOG_VERSION = 2; -export const CURRENT_PLAN_VERSION = 1; +export const CURRENT_CONNECTION_STORE_VERSION = 1; +export const CURRENT_PLAN_VERSION = 2; export const CURRENT_RESULT_VERSION = 1; export const AUTO_ASSIGNMENT = "auto"; diff --git a/src/core/eligibility.js b/src/core/eligibility.js new file mode 100644 index 0000000..d622f21 --- /dev/null +++ b/src/core/eligibility.js @@ -0,0 +1,129 @@ +import { classifyPricingEvidence } from "./pricing.js"; +import { + COST_POLICIES, + COST_PREFERENCES, + MODEL_ROLES, + PAID_ELIGIBILITY, +} from "./constants.js"; + +function classifyModelPricing(model, options) { + return classifyPricingEvidence(model?.pricing, options); +} + +function modelEnabled(settings, modelId) { + const control = settings?.modelControls?.[modelId]; + const selection = + typeof control?.enabled === "boolean" + ? control.enabled + ? "enabled" + : "disabled" + : (control?.selection ?? "policy"); + return ( + selection === "enabled" || + (selection === "policy" && settings?.autoIncludeNewModels !== false) + ); +} + +function modelSupports({ model, role, modalities, access }) { + const roleScore = model?.roles?.[role]; + if (!MODEL_ROLES.includes(role) || !Number.isInteger(roleScore) || roleScore <= 0) + return false; + if (role === "orchestrator" && model.canOrchestrate !== true) return false; + if ( + (role === "orchestrator" || role === "code-worker" || role === "vision-worker") && + model.toolCall === false + ) + return false; + if (role === "vision-worker" && model.toolCall !== true) return false; + if (!model.access?.includes(access)) return false; + if (!modalities.every((modality) => model.modalities?.input?.includes(modality))) + return false; + return model.modalities?.output?.includes("text") === true; +} + +export function resolveEligibility({ + model, + connection = null, + connections, + settings, + role = null, + modalities = ["text"], + access = "read", + hostInventory = null, + now = Date.now(), +} = {}) { + const blockingReasons = []; + const warnings = []; + const pricingStatus = classifyModelPricing(model, { now }); + const costPolicy = settings?.costPolicy; + const paidEligibility = PAID_ELIGIBILITY.includes(settings?.paidEligibility) + ? settings.paidEligibility + : "verified-pricing"; + + if ( + !COST_POLICIES.includes(costPolicy) || + !COST_PREFERENCES.includes(settings?.costPreference) + ) { + blockingReasons.push("invalid-settings"); + } + + if (!modelEnabled(settings, model?.id)) blockingReasons.push("disabled-by-you"); + if ( + model?.available === false || + settings?.modelControls?.[model?.id]?.available === false + ) + blockingReasons.push("host-model-missing"); + + if (model?.api?.urlValid === false) blockingReasons.push("invalid-endpoint"); + + if (connection?.entitlement === "reported-revoked") + blockingReasons.push("entitlement-revoked"); + + const savedBinding = role + ? settings?.roleConnections?.[role] + : null; + if (savedBinding) { + if (!connection) { + if (Array.isArray(connections)) + blockingReasons.push("connection-binding-changed"); + } else if ( + savedBinding.connectionId !== connection.id || + savedBinding.bindingRevision !== connection.bindingRevision + ) + blockingReasons.push("connection-binding-changed"); + } + + if (hostInventory && model?.id && !hostInventory.has(model.id)) + blockingReasons.push("host-model-missing"); + + if (costPolicy === "free-only") { + if (pricingStatus !== "free") blockingReasons.push("free-access-unverified"); + if (connection?.billing?.kind === "subscription") + blockingReasons.push("free-access-unverified"); + } else if (paidEligibility === "verified-pricing") { + if (pricingStatus === "unknown") + blockingReasons.push("legacy-paid-pricing-required"); + } else if (pricingStatus === "unknown") { + warnings.push("api-estimate-unavailable"); + if (model?.pricing?.reasons?.includes("public-price-route-mismatch")) + warnings.push("public-price-route-mismatch"); + if (model?.pricing?.reasons?.includes("pricing-expired")) + warnings.push("pricing-expired"); + } + + if ( + role && + model && + !modelSupports({ model, role, modalities, access }) + ) + blockingReasons.push("incompatible-capabilities"); + + return { + allowed: blockingReasons.length === 0, + blockingReasons: [...new Set(blockingReasons)], + warnings: [...new Set(warnings)], + pricingStatus, + connectionId: connection?.id ?? null, + bindingRevision: connection?.bindingRevision ?? null, + }; +} diff --git a/src/core/index.js b/src/core/index.js index cff8c32..22dc94b 100644 --- a/src/core/index.js +++ b/src/core/index.js @@ -4,6 +4,7 @@ export { COMPLEXITIES, COST_POLICIES, COST_PREFERENCES, + PAID_ELIGIBILITY, KNOWN_MODEL_IDS, MODEL_ROLES, MODALITIES, @@ -31,6 +32,7 @@ export { validateSettings, assertExplicitAssignments, } from "./settings.js"; +export { resolveEligibility } from "./eligibility.js"; export { planRoute } from "./planner.js"; export { sanitizeResult, sanitizeText } from "./sanitize.js"; diff --git a/src/core/planner.js b/src/core/planner.js index 796fa6f..9122671 100644 --- a/src/core/planner.js +++ b/src/core/planner.js @@ -112,11 +112,12 @@ function requirementsFor(role, route, task) { }; } -function assignmentFor({ role, route, task, catalog, settings }) { +function assignmentFor({ role, route, task, catalog, settings, connections }) { const requirements = requirementsFor(role, route, task); const candidates = eligibleModelsForRole({ catalog, settings, + connections, role, modalities: requirements.modalities, access: requirements.access, @@ -133,15 +134,18 @@ function assignmentFor({ role, route, task, catalog, settings }) { ? "NO_ELIGIBLE_FREE_MODEL" : "NO_ELIGIBLE_MODEL" : "INVALID_ROLE_ASSIGNMENT"; - const qualifier = freeOnly ? "verified-free" : "known-cost"; + const qualifier = freeOnly ? "verified-free" : settings.paidEligibility === "configured-connections" ? "configured" : "known-cost"; throw routerError(code, `No eligible ${qualifier} model can serve ${role}.`, { role, }); } + const connection = connections?.find(item => item.providerId === selected.id.split("/")[0]); return { role, modelId: selected.id, + connectionId: connection?.id ?? null, + bindingRevision: connection?.bindingRevision ?? null, // These legacy fields remain in the version-one route contract, but the // runtime does not execute alternate-model fallbacks. The persisted // maxFallbacksPerAssignment setting controls only the optional @@ -173,6 +177,7 @@ export function planRoute({ task, catalog = loadModelCatalog(), settings = createDefaultSettings(catalog), + connections, } = {}) { const normalizedTask = normalizeTask(task); if (normalizedTask.delegationDepth > 0) { @@ -194,6 +199,7 @@ export function planRoute({ task: normalizedTask, catalog, settings: normalizedSettings, + connections, }), ); @@ -205,6 +211,7 @@ export function planRoute({ freeOnly: normalizedSettings.costPolicy === "free-only", costPreference: normalizedSettings.costPreference, costPolicy: normalizedSettings.costPolicy, + paidEligibility: normalizedSettings.paidEligibility, maxDelegationDepth: normalizedSettings.maxDelegationDepth, maxFallbacksPerAssignment: normalizedSettings.maxFallbacksPerAssignment, diff --git a/src/core/pricing.js b/src/core/pricing.js index 7287e98..964ef72 100644 --- a/src/core/pricing.js +++ b/src/core/pricing.js @@ -100,8 +100,12 @@ export function analyzeRates(cost, modes) { }; } +function isUnspecifiedEndpoint(value) { + return value === undefined || value === null || value === ""; +} + function safeUrl(value) { - if (value === undefined || value === null) return null; + if (isUnspecifiedEndpoint(value)) return null; try { const url = new URL(value); if ( @@ -118,12 +122,12 @@ function safeUrl(value) { } } export function normalizeApiIdentity(value) { - const url = safeUrl(value?.url); + const unspecified = isUnspecifiedEndpoint(value?.url); + const url = unspecified ? null : safeUrl(value?.url); // Do not let redaction turn an invalid endpoint into an absent endpoint. // Preserve this flag through repeated normalization and persisted snapshots. const urlValid = - value?.urlValid !== false && - (value?.url === undefined || value?.url === null || url !== null); + value?.urlValid !== false && (unspecified || url !== null); return { id: typeof value?.id === "string" && @@ -139,6 +143,19 @@ export function normalizeApiIdentity(value) { urlValid, }; } +export function publicRatesApply(publicApi, liveApi) { + const pub = normalizeApiIdentity(publicApi); + const live = normalizeApiIdentity(liveApi); + return ( + pub.urlValid && + live.urlValid && + pub.id !== null && + pub.npm !== null && + pub.id === live.id && + pub.npm === live.npm && + pub.url === live.url + ); +} const tri = (value) => (typeof value === "boolean" ? value : null); const limit = (value) => (Number.isInteger(value) && value > 0 ? value : null); export function capabilityDetails(value, source, observedAt, cli = false) { @@ -284,15 +301,23 @@ export function resolveModelEvidence(live, snapshot) { const record = snapshot?.models?.[live.id]; if (!record) return unknownPricing("model-not-in-public-source"); const api = normalizeApiIdentity(live.api); - const conflict = - ["id", "npm", "url"].some((key) => api[key] !== record.api[key]) || + const publicApi = normalizeApiIdentity(record.api); + const identityConflict = !api.id || !api.npm || !api.urlValid || - record.api.urlValid === false; + publicApi.urlValid === false || + api.id !== publicApi.id || + api.npm !== publicApi.npm; + const priceRouteMismatch = + !identityConflict && !publicRatesApply(publicApi, api); return { ...record.pricing, - ...(conflict ? { class: "unknown", reasons: ["identity-conflict"] } : {}), + ...(identityConflict + ? { class: "unknown", reasons: ["identity-conflict"] } + : priceRouteMismatch + ? { class: "unknown", reasons: ["public-price-route-mismatch"] } + : {}), source: MODELS_DEV_URL, digest: snapshot.digest, fetchedAt: snapshot.fetchedAt, diff --git a/src/core/settings.js b/src/core/settings.js index 0c68bc2..04a14cb 100644 --- a/src/core/settings.js +++ b/src/core/settings.js @@ -4,6 +4,7 @@ import { COST_PREFERENCES, CURRENT_SETTINGS_VERSION, MODEL_ROLES, + PAID_ELIGIBILITY, ROLE_REQUIREMENTS, } from "./constants.js"; import { @@ -87,6 +88,74 @@ function defaultRoleAssignmentsForCatalog(catalog) { ); } +function emptyRoleConnections() { + return Object.fromEntries(MODEL_ROLES.map((role) => [role, null])); +} + +function normalizeRoleConnections(value) { + if (value !== undefined && !isPlainObject(value)) + invalidSettings("roleConnections must be an object."); + const supplied = value ?? {}; + for (const role of Object.keys(supplied)) { + if (!MODEL_ROLES.includes(role)) + invalidSettings("Settings contain an unknown role."); + } + return Object.fromEntries( + MODEL_ROLES.map((role) => { + const binding = Object.hasOwn(supplied, role) ? supplied[role] : null; + if (binding === null || binding === undefined) return [role, null]; + if ( + !isPlainObject(binding) || + typeof binding.connectionId !== "string" || + !/^[a-f0-9]{32}$/.test(binding.connectionId) || + typeof binding.bindingRevision !== "string" || + !/^[a-f0-9]{32}$/.test(binding.bindingRevision) + ) + invalidSettings(`Role connection for ${role} is invalid.`); + return [ + role, + { + connectionId: binding.connectionId, + bindingRevision: binding.bindingRevision, + }, + ]; + }), + ); +} + +function normalizeBillingDeclarations(value) { + if (value === undefined || value === null) return {}; + if (!isPlainObject(value)) + invalidSettings("billingDeclarations must be an object."); + return Object.fromEntries( + Object.entries(value).map(([connectionId, declaration]) => { + if (!/^[a-f0-9]{32}$/.test(connectionId)) + invalidSettings("Billing declaration identity is invalid."); + if ( + !isPlainObject(declaration) || + !["subscription", "metered-api", "prepaid", "local", "free", "unknown"].includes( + declaration.kind, + ) || + typeof declaration.bindingRevision !== "string" || + !/^[a-f0-9]{32}$/.test(declaration.bindingRevision) || + (declaration.declaredAt !== undefined && + (typeof declaration.declaredAt !== "string" || + !Number.isFinite(Date.parse(declaration.declaredAt)))) + ) + invalidSettings("Billing declaration is invalid."); + return [ + connectionId, + { + kind: declaration.kind, + bindingRevision: declaration.bindingRevision, + source: "user-declared", + ...(declaration.declaredAt ? { declaredAt: declaration.declaredAt } : {}), + }, + ]; + }), + ); +} + function normalizeRoleAssignments(value, catalog) { if (value !== undefined && !isPlainObject(value)) { invalidSettings("roleAssignments must be an object."); @@ -124,6 +193,7 @@ export function assertExplicitAssignments( settings, catalog, roles = MODEL_ROLES, + connections, ) { for (const role of roles) { const modelId = settings.roleAssignments[role]; @@ -135,6 +205,7 @@ export function assertExplicitAssignments( role, modalities: [...requirement.modalities], access: requirement.access, + connections, }); if (!eligible.some((model) => model.id === modelId)) { invalidSettings( @@ -159,6 +230,9 @@ export function validateSettings(value, catalog = loadModelCatalog()) { if (!COST_POLICIES.includes(value.costPolicy)) { invalidSettings("costPolicy is unsupported."); } + if (!PAID_ELIGIBILITY.includes(value.paidEligibility)) { + invalidSettings("paidEligibility is unsupported."); + } if ( !Number.isInteger(value.maxDelegationDepth) || value.maxDelegationDepth < 0 || @@ -190,10 +264,13 @@ export function validateSettings(value, catalog = loadModelCatalog()) { autoIncludeNewModels: value.autoIncludeNewModels ?? true, costPreference: value.costPreference, costPolicy: value.costPolicy, + paidEligibility: value.paidEligibility, roleAssignments: normalizeRoleAssignments( value.roleAssignments, normalizedCatalog, ), + roleConnections: normalizeRoleConnections(value.roleConnections), + billingDeclarations: normalizeBillingDeclarations(value.billingDeclarations), maxDelegationDepth: value.maxDelegationDepth, maxFallbacksPerAssignment: value.maxFallbacksPerAssignment, makeRouterDefault: value.makeRouterDefault ?? true, @@ -211,7 +288,10 @@ export function createDefaultSettings(catalog = loadModelCatalog()) { schemaVersion: CURRENT_SETTINGS_VERSION, costPreference: DEFAULT_COST_PREFERENCE, costPolicy: DEFAULT_COST_POLICY, + paidEligibility: "verified-pricing", roleAssignments: defaultRoleAssignmentsForCatalog(normalizedCatalog), + roleConnections: emptyRoleConnections(), + billingDeclarations: {}, maxDelegationDepth: 1, maxFallbacksPerAssignment: 1, makeRouterDefault: true, @@ -281,6 +361,9 @@ export function migrateSettings(value, catalog = loadModelCatalog()) { if (value.schemaVersion === CURRENT_SETTINGS_VERSION) { return validateSettings(value, normalizedCatalog); } + if (value.schemaVersion === 3) { + return validateSettings(upgradeV3ToV4(value), normalizedCatalog); + } if ( value.schemaVersion !== undefined && value.schemaVersion !== 0 && @@ -319,8 +402,8 @@ export function migrateSettings(value, catalog = loadModelCatalog()) { controls[id] = { ...controls[id], available: false }; return validateSettings( - { - schemaVersion: CURRENT_SETTINGS_VERSION, + upgradeV3ToV4({ + schemaVersion: 3, costPreference: value.costPreference ?? DEFAULT_COST_PREFERENCE, costPolicy: value.costPolicy ?? DEFAULT_COST_POLICY, autoIncludeNewModels: true, @@ -338,11 +421,21 @@ export function migrateSettings(value, catalog = loadModelCatalog()) { ? value.makeRouterDefault : true, modelControls: controls, - }, + }), normalizedCatalog, ); } +function upgradeV3ToV4(value) { + return { + ...value, + schemaVersion: CURRENT_SETTINGS_VERSION, + paidEligibility: "verified-pricing", + roleConnections: emptyRoleConnections(), + billingDeclarations: {}, + }; +} + export const DEFAULT_SETTINGS = deepFreeze(createDefaultSettings()); export function cloneDefaultSettings() { diff --git a/src/core/usage-accounting.js b/src/core/usage-accounting.js new file mode 100644 index 0000000..13fa23e --- /dev/null +++ b/src/core/usage-accounting.js @@ -0,0 +1,156 @@ +import { routerError } from "./errors.js"; +import { isPlainObject } from "./utils.js"; +import { BILLING_KINDS, EVIDENCE_SOURCES } from "./connections.js"; + +const TOKEN_KEYS = Object.freeze([ + "input", + "output", + "reasoning", + "cacheRead", + "cacheWrite", +]); + +function invalidUsage(message) { + throw routerError("INVALID_USAGE", message); +} + +export function nullableFinite(value) { + if (value === null || value === undefined) return null; + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) + invalidUsage("Usage values must be finite and nonnegative."); + return value; +} + +export function sumNullable(values) { + const present = values.filter((value) => value !== null && value !== undefined); + if (!present.length) return null; + const total = present.reduce((sum, value) => sum + value, 0); + return Number.isFinite(total) ? total : null; +} + +export function estimateApiCost({ rates, tokens, semantics = {} } = {}) { + if (!isPlainObject(rates) || !isPlainObject(tokens)) { + return { amount: null, status: "unavailable", currency: null }; + } + const unavailable = { amount: null, status: "unavailable", currency: null }; + if (!isPlainObject(semantics)) return unavailable; + // Unsupported rate dimensions (tiered, audio, long-context, etc.) cannot + // silently fall back to ordinary text prices. + const supported = new Set(["input", "output", "reasoning", "cache_read", "cache_write"]); + if (Object.entries(rates).some(([key, value]) => !supported.has(key) || typeof value !== "number" || !Number.isFinite(value) || value < 0)) return unavailable; + if (semantics.audioRequired || Object.keys(semantics).some(key => !["reasoningIncludedInOutput", "cacheIncludedInInput", "audioRequired"].includes(key))) return unavailable; + for (const key of ["reasoningIncludedInOutput", "cacheIncludedInInput"]) { + if (semantics[key] != null && typeof semantics[key] !== "boolean") return unavailable; + } + if (tokens.reasoning > 0 && typeof semantics.reasoningIncludedInOutput !== "boolean") return unavailable; + if ((tokens.cacheRead > 0 || tokens.cacheWrite > 0) && typeof semantics.cacheIncludedInInput !== "boolean") return unavailable; + const input = nullableFinite(tokens.input); + const output = nullableFinite(tokens.output); + if (input === null || output === null || rates.input == null || rates.output == null) { + return { amount: null, status: "unavailable", currency: null }; + } + let amount = (input * rates.input + output * rates.output) / 1_000_000; + if (semantics.reasoningIncludedInOutput !== true && tokens.reasoning != null) { + if (rates.reasoning == null) + return { amount: null, status: "partial", currency: "USD", incomplete: true }; + amount += (nullableFinite(tokens.reasoning) * rates.reasoning) / 1_000_000; + } + if (semantics.cacheIncludedInInput !== true) { + for (const [key, rateKey] of [ + ["cacheRead", "cache_read"], + ["cacheWrite", "cache_write"], + ]) { + if (tokens[key] == null) continue; + if (rates[rateKey] == null) + return { amount: null, status: "partial", currency: "USD", incomplete: true }; + amount += (nullableFinite(tokens[key]) * rates[rateKey]) / 1_000_000; + } + } + if (semantics.audioRequired && (rates.input_audio == null || rates.output_audio == null)) { + return { amount: null, status: "unavailable", currency: null }; + } + return { + amount: Number.isFinite(amount) ? amount : null, + status: "estimated-api-cost", + currency: "USD", + }; +} + +// Retain only public rate evidence. Missing semantics intentionally keeps this +// historical snapshot unsuitable for a complete API estimate. +export function capturePriceSnapshot(pricing) { + if (!isPlainObject(pricing) || !isPlainObject(pricing.rates) || !Number.isFinite(Date.parse(pricing.fetchedAt)) || !Number.isFinite(Date.parse(pricing.expiresAt))) return null; + const keys = ["input", "output", "reasoning", "cache_read", "cache_write"]; + if (Object.entries(pricing.rates).some(([key, value]) => !keys.includes(key) || typeof value !== "number" || !Number.isFinite(value) || value < 0)) return null; + if (pricing.rates.input == null || pricing.rates.output == null || typeof pricing.source !== "string" || pricing.source.length > 2048) return null; + let source; + try { + source = new URL(pricing.source); + if (source.protocol !== "https:" || source.username || source.password || source.search || source.hash) return null; + } catch { return null; } + return { rates: Object.fromEntries(keys.filter(key => pricing.rates[key] != null).map(key => [key, pricing.rates[key]])), source: source.href, fetchedAt: new Date(pricing.fetchedAt).toISOString(), expiresAt: new Date(pricing.expiresAt).toISOString(), semantics: null }; +} + +export function validateUsageObservation(value) { + if (!isPlainObject(value)) invalidUsage("Usage observation is invalid."); + if (typeof value.eventKey !== "string" || !/^[a-f0-9]{64}$/.test(value.eventKey)) + invalidUsage("Usage observation identity is invalid."); + if ( + typeof value.observedAt !== "string" || + !Number.isFinite(Date.parse(value.observedAt)) + ) + invalidUsage("Usage observation time is invalid."); + if ( + value.connectionId !== null && + (typeof value.connectionId !== "string" || + !/^[a-f0-9]{32}$/.test(value.connectionId)) + ) + invalidUsage("Usage observation connection is invalid."); + if ( + value.bindingRevision !== null && + (typeof value.bindingRevision !== "string" || + !/^[a-f0-9]{32}$/.test(value.bindingRevision)) + ) + invalidUsage("Usage observation binding is invalid."); + if (!BILLING_KINDS.includes(value.billingKind)) + invalidUsage("Usage observation billing is invalid."); + if (!EVIDENCE_SOURCES.includes(value.billingSource)) + invalidUsage("Usage observation billing is invalid."); + const tokens = value.tokens ?? {}; + const recorded = + value.recordedCost === null || value.recordedCost === undefined + ? null + : { + amount: nullableFinite(value.recordedCost.amount), + currency: + value.recordedCost.currency === null || + value.recordedCost.currency === undefined + ? null + : typeof value.recordedCost.currency === "string" && /^[A-Z]{3}$/.test(value.recordedCost.currency) + ? value.recordedCost.currency + : invalidUsage("Usage observation currency is invalid."), + }; + return { + eventKey: value.eventKey, + observedAt: value.observedAt, + connectionId: value.connectionId, + bindingRevision: value.bindingRevision, + billingKind: value.billingKind, + billingSource: value.billingSource, + tokens: Object.fromEntries( + TOKEN_KEYS.map((key) => [key, nullableFinite(tokens[key] ?? null)]), + ), + recordedCost: recorded, + priceSnapshot: capturePriceSnapshot(value.priceSnapshot), + priceSnapshotId: capturePriceSnapshot(value.priceSnapshot) && typeof value.priceSnapshotId === "string" && /^[a-f0-9]{64}$/.test(value.priceSnapshotId) ? value.priceSnapshotId : null, + + }; +} + +export function costLabel(kind) { + if (kind === "opencode-recorded") return "OpenCode-recorded cost"; + if (kind === "estimated-api") return "Estimated API cost"; + if (kind === "api-equivalent") return "API-equivalent estimate"; + if (kind === "provider-charge") return "Provider-reported charge"; + return "Not reported"; +} diff --git a/src/installer/index.js b/src/installer/index.js index 016fafb..2b7f904 100644 --- a/src/installer/index.js +++ b/src/installer/index.js @@ -35,7 +35,7 @@ const MAX_MCP_OUTPUT_BYTES = 1024 * 1024; const MCP_HANDSHAKE_TIMEOUT_MS = 10_000; const MCP_PROTOCOL_VERSION = "2025-11-25"; const RECEIPT_SCHEMA_VERSION = 1; -const MANAGED_SURFACE_VERSION = 2; +const MANAGED_SURFACE_VERSION = 3; const OWNED_ROOTS = ["mcp", "tools", "agent"]; const OWNED_PATHS = [ ["mcp", "model-control"], diff --git a/src/mcp/server.js b/src/mcp/server.js index e2ce7d6..8c15a42 100644 --- a/src/mcp/server.js +++ b/src/mcp/server.js @@ -47,6 +47,7 @@ function compactRoute(result) { schemaVersion: result.schemaVersion, settingsRevision: result.settingsRevision, catalogRevision: result.catalogRevision, + connectionRevision: result.connectionRevision, policy: result.policy, route: result.route, task, @@ -54,6 +55,8 @@ function compactRoute(result) { role: assignment.role, agentId: agentForRole(assignment.role), modelId: assignment.modelId, + connectionId: assignment.connectionId ?? null, + bindingRevision: assignment.bindingRevision ?? null, fallbackModelId: assignment.fallbackModelId ?? null, mayDelegate: assignment.mayDelegate === true, })), @@ -68,11 +71,15 @@ function compactStatus(state) { schemaVersion: state.schemaVersion, settingsRevision: state.settingsRevision, catalogRevision: state.catalogRevision, + connectionRevision: state.connectionRevision, + connections: state.connections ?? [], + roleConnections: state.settings.roleConnections ?? {}, blockedRoles: state.blockedRoles, policy: { localOnly: true, freeOnly: costPolicy === "free-only", costPolicy, + paidEligibility: state.settings.paidEligibility, costPreference: state.settings.costPreference, maxDelegationDepth: state.settings.maxDelegationDepth, maxFallbacksPerAssignment: state.settings.maxFallbacksPerAssignment, diff --git a/src/opencode/connection-observer.js b/src/opencode/connection-observer.js new file mode 100644 index 0000000..40f88aa --- /dev/null +++ b/src/opencode/connection-observer.js @@ -0,0 +1,104 @@ +import { + PROVIDER_ID_PATTERN, + deriveBindingRevision, + deriveConnectionId, + validateConnection, + connectionBindingInputs, +} from "../core/connections.js"; +import { isPlainObject } from "../core/utils.js"; + +function iso(now) { + return new Date(now).toISOString(); +} + +function previousByProvider(previousConnections) { + return new Map( + (previousConnections ?? []).map((connection) => [ + connection.providerId, + connection, + ]), + ); +} + +export function providersFromLiveModels(models) { + const byProvider = new Map(); + for (const model of models ?? []) { + const providerId = + typeof model?.provider === "string" + ? model.provider + : String(model?.id ?? "").split("/")[0]; + if (!providerId) continue; + const key = + typeof model?.id === "string" && model.id.startsWith(`${providerId}/`) + ? model.id.slice(providerId.length + 1) + : String(model?.api?.id ?? model?.id ?? ""); + if (!key) continue; + const slot = byProvider.get(providerId) ?? { id: providerId, models: {} }; + slot.models[key] = { + id: key, + providerID: providerId, + api: model.api, + }; + byProvider.set(providerId, slot); + } + return [...byProvider.values()]; +} + +export function observeConnections({ + providers, + previousConnections = [], + scopeId, + now = Date.now(), +} = {}) { + if (!Array.isArray(providers)) return []; + const previous = previousByProvider(previousConnections); + const observedAt = iso(now); + const connections = []; + const seen = new Set(); + for (const provider of providers) { + if (!isPlainObject(provider) || typeof provider.id !== "string") continue; + if (!PROVIDER_ID_PATTERN.test(provider.id)) continue; + if (seen.has(provider.id)) continue; + seen.add(provider.id); + const binding = connectionBindingInputs(provider); + const prior = previous.get(provider.id); + const authKind = "unknown"; + const billingKind = "unknown"; + const bindingRevision = deriveBindingRevision({ + url: binding.url, + npm: binding.npm, + authKind, + billingKind, + mixed: binding.mixed, + }); + const billingUnchanged = + prior && + prior.bindingRevision === bindingRevision && + prior.billing?.source === "user-declared"; + connections.push( + validateConnection({ + id: deriveConnectionId(scopeId, provider.id), + providerId: provider.id, + bindingRevision, + authKind, + billing: billingUnchanged + ? prior.billing + : { + kind: "unknown", + source: "unknown", + observedAt: null, + }, + transportVisibility: binding.declared + ? "declared-endpoint" + : "host-managed", + inventoryObservedAt: observedAt, + entitlement: + prior?.entitlement === "reported-revoked" + ? "reported-revoked" + : "not-reported", + quota: prior?.quota ?? null, + }), + ); + } + return connections; +} diff --git a/src/opencode/plugin-runtime.js b/src/opencode/plugin-runtime.js index d9a7204..80856e1 100644 --- a/src/opencode/plugin-runtime.js +++ b/src/opencode/plugin-runtime.js @@ -1,3 +1,6 @@ +import { capturePriceSnapshot } from "../core/usage-accounting.js"; +import { createAttributionQueue } from "../server/attribution-queue.js"; +import { setAttributionDiagnostics, persistAttributionDiagnostics } from "../server/usage-attribution-store.js"; import { AUTO_ASSIGNMENT, eligibleModelsForRole, @@ -9,6 +12,7 @@ import { resolveSettingsPath } from "../server/settings-store.js"; import { readControlSnapshot } from "../server/state-snapshot.js"; import { normalizeApiIdentity } from "../core/pricing.js"; import { classifyRouteRequest } from "../server/task-classifier.js"; +import { observeConnections } from "./connection-observer.js"; const ROUTER_AGENT = "omc-router"; const MEDIA_MODALITIES = Object.freeze(["image", "audio", "video", "pdf"]); @@ -264,13 +268,22 @@ function positiveRate(value) { typeof value === "object" && Object.values(value).some(positiveRate); } -function optionsMatch(options, api, depth = 0) { +function optionsMatch(options, api, depth = 0, allowOpaqueFetch = false) { if (!options || typeof options !== "object") return true; if (depth > 12) return false; for (const [key, value] of Object.entries(options)) { if (/^(headers|apiKey|token|accessToken|credentials|timeout)$/i.test(key)) continue; - if (/^(fetch|dispatcher|proxy|proxyUrl)$/i.test(key)) return false; + if (/^(fetch|dispatcher|proxy|proxyUrl)$/i.test(key)) { + if ( + allowOpaqueFetch && + depth === 0 && + /^fetch$/i.test(key) && + typeof value === "function" + ) + continue; + return false; + } if (/^(baseURL|baseUrl|url|endpoint|apiEndpoint)$/i.test(key)) { const normalized = normalizeApiIdentity({ ...api, url: value }); if ( @@ -294,11 +307,75 @@ function optionsMatch(options, api, depth = 0) { return true; } +function applyLiveConnections(current, host) { + if (!current.connectionScopeId || !Array.isArray(host?.providers)) return; + current.connections = observeConnections({ + providers: host.providers, + previousConnections: current.connections, + scopeId: current.connectionScopeId, + }); +} + +async function completeAttribution({ settingsPath, sessionID, messageID, info, route, observedAt }) { + const { + attributionEventKey, + readOrCreateAttributionSalt, + upsertUsageObservation, + } = await import("../server/usage-attribution-store.js"); + const salt = await readOrCreateAttributionSalt(settingsPath); + const tokens = info?.tokens ?? {}; + await upsertUsageObservation({ + settingsPath, + pending: false, + pendingEventKey: attributionEventKey(salt, sessionID, route.messageID), + observation: { + eventKey: attributionEventKey(salt, sessionID, messageID), + observedAt, + connectionId: route?.connectionId ?? null, + bindingRevision: route?.bindingRevision ?? null, + billingKind: route?.billingKind ?? "unknown", + billingSource: route?.billingSource ?? "unknown", + tokens: { + input: tokens.input ?? null, + output: tokens.output ?? null, + reasoning: tokens.reasoning ?? null, + cacheRead: tokens.cache?.read ?? null, + cacheWrite: tokens.cache?.write ?? null, + }, + recordedCost: + typeof info?.cost === "number" + ? { amount: info.cost, currency: null } + : null, + priceSnapshot: route?.priceSnapshot ?? null, + priceSnapshotId: null, + }, + }); +} + +async function captureAttribution({ settingsPath, sessionID, route }) { + const { attributionEventKey, readOrCreateAttributionSalt, upsertUsageObservation } = await import("../server/usage-attribution-store.js"); + const salt = await readOrCreateAttributionSalt(settingsPath); + await upsertUsageObservation({ settingsPath, pending: true, observation: { + eventKey: attributionEventKey(salt, sessionID, route.messageID), + observedAt: route.observedAt, + connectionId: route.connectionId ?? null, bindingRevision: route.bindingRevision ?? null, + billingKind: route.billingKind ?? "unknown", billingSource: route.billingSource ?? "unknown", + tokens: {}, recordedCost: null, priceSnapshot: route.priceSnapshot ?? null, priceSnapshotId: null, + } }); +} + export function createMediaRoutingHooks({ loadPolicy = loadSavedRoutingPolicy, client, directory, + recordUsage = false, + settingsPath = resolveSettingsPath(), } = {}) { + const attributionQueue = createAttributionQueue({ + onState: state => setAttributionDiagnostics(settingsPath, state), + persist: state => persistAttributionDiagnostics(settingsPath, state), + }); + const queueAttribution = work => attributionQueue.enqueue(work); const routes = new Map(); const readOnlySessions = new Set(); const workflows = new Map(); @@ -326,6 +403,8 @@ export function createMediaRoutingHooks({ parent: operation.parent, childID, id: childRoute.id, + connectionId: childRoute.connectionId ?? null, + bindingRevision: childRoute.bindingRevision ?? null, messageID: childRoute.messageID, }; children.set(childID, assignment); @@ -343,7 +422,12 @@ export function createMediaRoutingHooks({ try { const value = await loadPolicy(); const catalog = validateCatalog(value.catalog); - return { catalog, settings: migrateSettings(value.settings, catalog) }; + return { + catalog, + settings: migrateSettings(value.settings, catalog), + connections: value.connections ?? [], + connectionScopeId: value.connectionScopeId, + }; } catch { fail("OMC_MEDIA_POLICY_UNAVAILABLE"); } @@ -363,15 +447,27 @@ export function createMediaRoutingHooks({ models.set(`${provider.id}/${id}`, model); } } + models.providers = result.data.providers; return models; } catch { fail("OMC_HOST_INVENTORY_UNAVAILABLE"); } } - function select(current, host, requirements, retainedID) { + function connectionFor(current, modelId) { + const providerId = String(modelId ?? "").split("/")[0]; + return ( + current.connections?.find((item) => item.providerId === providerId) ?? null + ); + } + function select(current, host, requirements, retained) { + const retainedID = typeof retained === "string" ? retained : retained?.id; const configured = retainedID ?? current.settings.roleAssignments[requirements.role]; - const candidates = eligibleModelsForRole({ ...current, ...requirements }); + const candidates = eligibleModelsForRole({ + ...current, + ...requirements, + connections: current.connections, + }); const selected = configured === AUTO_ASSIGNMENT ? candidates.find( @@ -388,10 +484,29 @@ export function createMediaRoutingHooks({ !hostSupports(host.get(selected.id), requirements) ) fail("OMC_DISPATCH_IDENTITY_CONFLICT"); - return selected; + const connection = connectionFor(current, selected.id); + const expected = + retained && typeof retained === "object" + ? retained + : current.settings.roleConnections?.[requirements.role]; + if (expected?.connectionId) { + if (!connection) fail("OMC_DISPATCH_IDENTITY_CONFLICT"); + if ( + connection.id !== expected.connectionId || + connection.bindingRevision !== expected.bindingRevision + ) + fail("OMC_DISPATCH_IDENTITY_CONFLICT"); + } + if (connection?.entitlement === "reported-revoked") + fail("OMC_DISPATCH_IDENTITY_CONFLICT"); + return { ...selected, connection }; } return { async event({ event }) { + if (event?.type === "server.instance.disposed") { + if (recordUsage) await attributionQueue.flush({ close: true }); + return; + } if (event?.type === "message.updated") { const info = event.properties?.info, route = routes.get(info?.sessionID); @@ -415,6 +530,17 @@ export function createMediaRoutingHooks({ background.delete(info.sessionID); } } + // Every completed assistant step can be billable, including tool calls. + // Keep repair authorization above independent of asynchronous accounting. + if (recordUsage && route?.attribution && info?.role === "assistant" && + info.agent === route.agent && info.parentID === route.messageID && + info.time?.completed && typeof info.id === "string") { + const snapshot = { ...route.attribution }; + const completedInfo = structuredClone(info); + const observedAt = new Date().toISOString(); + queueAttribution(() => completeAttribution({ settingsPath, + sessionID: info.sessionID, messageID: info.id, info: completedInfo, route: snapshot, observedAt })); + } return; } if (event?.type !== "session.deleted") return; @@ -468,11 +594,12 @@ export function createMediaRoutingHooks({ }; const current = await policy(); const host = await inventory(); + applyLiveConnections(current, host); const selected = select( current, host, requirements, - authorizedRepair?.id, + authorizedRepair, ); output.message.model = modelReference(selected.id); delete output.message.variant; @@ -492,6 +619,15 @@ export function createMediaRoutingHooks({ routes.set(input.sessionID, { repair: authorizedRepair, id: selected.id, + connectionId: + selected.connection?.id ?? authorizedRepair?.connectionId ?? null, + bindingRevision: + selected.connection?.bindingRevision ?? + authorizedRepair?.bindingRevision ?? + null, + billingKind: selected.connection?.billing?.kind ?? "unknown", + billingSource: selected.connection?.billing?.source ?? "unknown", + priceSnapshotId: null, requirements, agent: output.message.agent, messageID: output.message.id, @@ -600,10 +736,13 @@ export function createMediaRoutingHooks({ fail("OMC_DISPATCH_ROUTE_MISSING"); const current = await policy(); const host = await inventory(); + applyLiveConnections(current, host); if ( - !eligibleModelsForRole({ ...current, ...route.requirements }).some( - (m) => m.id === route.id, - ) + !eligibleModelsForRole({ + ...current, + ...route.requirements, + connections: current.connections, + }).some((m) => m.id === route.id) ) fail(); const selected = select( @@ -613,10 +752,18 @@ export function createMediaRoutingHooks({ route.repair?.active && route.repair.messageID === input.message.id && route.repair.workflow === workflows.get(route.repair.parent) - ? route.repair.id + ? route.repair : undefined, ); const actual = input.model; + if ( + route.connectionId && + route.bindingRevision && + selected.connection && + (selected.connection.id !== route.connectionId || + selected.connection.bindingRevision !== route.bindingRevision) + ) + fail("OMC_DISPATCH_IDENTITY_CONFLICT"); if ( (input.provider?.id ?? input.provider?.info?.id) !== actual?.providerID || @@ -624,7 +771,12 @@ export function createMediaRoutingHooks({ `${actual?.providerID}/${actual?.id}` !== route.id || !identityMatches(selected.api, actual?.api) || !hostSupports(actual, route.requirements) || - !optionsMatch(input.provider?.options, selected.api) || + !optionsMatch( + input.provider?.options, + selected.api, + 0, + current.settings.costPolicy === "known-cost", + ) || !optionsMatch(actual?.options, selected.api) || !optionsMatch(output?.options, selected.api) ) @@ -641,6 +793,20 @@ export function createMediaRoutingHooks({ (selected.pricing.class === "free" && positiveRate(actual.cost)) ) fail("OMC_DISPATCH_PRICING_CONFLICT"); + if (recordUsage) { + // Capture binding before dispatch; completion cannot inherit a later login. + route.attribution ??= Object.freeze({ + messageID: route.messageID, + observedAt: new Date().toISOString(), + priceSnapshot: capturePriceSnapshot(selected.pricing), + connectionId: selected.connection?.id ?? route.connectionId ?? null, + bindingRevision: selected.connection?.bindingRevision ?? route.bindingRevision ?? null, + billingKind: selected.connection?.billing?.kind ?? "unknown", + billingSource: selected.connection?.billing?.source ?? "unknown", + }); + const snapshot = { ...route.attribution }; + queueAttribution(() => captureAttribution({ settingsPath, sessionID: input.sessionID, route: snapshot })); + } }, async "permission.ask"(input, output) { if (readOnlySessions.has(input?.sessionID)) output.status = "deny"; @@ -709,10 +875,12 @@ export function createMediaRoutingHooks({ current, await inventory(), { role: "code-worker", modalities: ["text"], access: "write" }, - child.id, + child, ); operation.repair = { id: child.id, + connectionId: child.connectionId ?? null, + bindingRevision: child.bindingRevision ?? null, parent: input.sessionID, workflow, active: true, @@ -748,5 +916,13 @@ export function createMediaRoutingHooks({ ) operation.slash.completed = true; }, + // OpenCode 1.18.28 awaits plugin dispose() finalizers. Event listeners are + // fire-and-forget, so shutdown durability uses this lifecycle hook. + async dispose() { + if (recordUsage) return attributionQueue.flush({ close: true }); + }, + async flushAttribution(options) { + return attributionQueue.flush(options); + }, }; } diff --git a/src/opencode/plugin.js b/src/opencode/plugin.js index 0a6da19..d54e520 100644 --- a/src/opencode/plugin.js +++ b/src/opencode/plugin.js @@ -3,4 +3,4 @@ import { createMediaRoutingHooks } from "./plugin-runtime.js"; // Keep this module's public surface to plugin functions only. OpenCode loads // every plugin function exported by a local plugin module. export const OmcRouterPlugin = async ({ client, directory } = {}) => - createMediaRoutingHooks({ client, directory }); + createMediaRoutingHooks({ client, directory, recordUsage: true }); diff --git a/src/server/app.js b/src/server/app.js index 30d0247..eea14a1 100644 --- a/src/server/app.js +++ b/src/server/app.js @@ -167,6 +167,7 @@ export async function handleApi( const body = await readJson(request, { maxBytes: MAX_SETTINGS_BYTES }); json(response, 200, await service.updateSettings(body?.settings ?? body, { expectedSettingsRevision: body?.expectedSettingsRevision, + expectedConnectionRevision: body?.expectedConnectionRevision, catalogRevision: body?.catalogRevision, })); return true; diff --git a/src/server/attribution-queue.js b/src/server/attribution-queue.js new file mode 100644 index 0000000..0e5b3c1 --- /dev/null +++ b/src/server/attribution-queue.js @@ -0,0 +1,37 @@ +import { randomUUID } from "node:crypto"; +// Accounting never runs on the provider critical path. Only queued functions, +// not already-started promises, enter this bounded serial worker. +export function createAttributionQueue({ limit = 1000, onState = () => {}, persist = async () => {} } = {}) { + const tasks = []; + let running = null, closed = false; + const state = { queueId: randomUUID(), pendingCount: 0, failedWriteCount: 0, droppedCount: 0, lastFailureCode: null }; + const publish = () => onState({ ...state }); + function start() { + if (running) return; + running = Promise.resolve().then(async () => { + while (tasks.length) { + const work = tasks.shift(); + try { await work(); } + catch { state.failedWriteCount++; state.lastFailureCode = 'ATTRIBUTION_WRITE_FAILED'; } + state.pendingCount--; publish(); + } + try { await persist({ ...state }); } + catch { state.failedWriteCount++; state.lastFailureCode = 'ATTRIBUTION_WRITE_FAILED'; publish(); } + }).finally(() => { running = null; if (tasks.length) start(); }); + } + return { + enqueue(work) { + if (closed || state.pendingCount >= limit) { state.droppedCount++; publish(); return false; } + tasks.push(work); state.pendingCount++; publish(); start(); return true; + }, + stats() { return { ...state }; }, + async flush({ timeoutMs = 2000, close = false } = {}) { + if (close) closed = true; + let timer; + const drain = async () => { while (running) await running; }; + const complete = await Promise.race([drain().then(() => true), new Promise(resolve => { timer = setTimeout(() => resolve(false), timeoutMs); })]); + clearTimeout(timer); + return { ...state, complete }; + }, + }; +} diff --git a/src/server/connection-store.js b/src/server/connection-store.js new file mode 100644 index 0000000..760ff52 --- /dev/null +++ b/src/server/connection-store.js @@ -0,0 +1,116 @@ +import { randomUUID, createHash } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + readFile, + rename, + unlink, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; + +import { + CURRENT_CONNECTION_STORE_VERSION, + validateConnectionSnapshot, +} from "../core/connections.js"; +import { withStateLock } from "./state-lock.js"; + +const MAX_CONNECTION_SNAPSHOT_BYTES = 1024 * 1024; + +export function resolveConnectionSnapshotPath(settingsPath) { + return join(dirname(settingsPath), "connections.json"); +} + +function emptySnapshot() { + const connections = []; + return { + schemaVersion: CURRENT_CONNECTION_STORE_VERSION, + revision: createHash("sha256").update(JSON.stringify(connections)).digest("hex"), + scopeId: randomUUID(), + connections, + }; +} + +export async function readConnectionSnapshot({ + settingsPath, + path = resolveConnectionSnapshotPath(settingsPath), + locked = false, +} = {}) { + const read = async () => { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw Object.assign(new Error("Connection snapshot must be a regular file."), { + code: "CONNECTION_SNAPSHOT_INVALID", + }); + } + if (metadata.size > MAX_CONNECTION_SNAPSHOT_BYTES) { + throw Object.assign(new Error("Connection snapshot is too large."), { + code: "CONNECTION_SNAPSHOT_TOO_LARGE", + }); + } + return validateConnectionSnapshot(JSON.parse(await readFile(path, "utf8"))); + } catch (error) { + if (error?.code === "ENOENT") { + const snapshot = emptySnapshot(); + await writeConnectionSnapshot({ + settingsPath, + snapshot, + path, + locked: true, + }); + return snapshot; + } + if (error instanceof SyntaxError) { + throw Object.assign(new Error("Connection snapshot is not valid JSON."), { + code: "CONNECTION_SNAPSHOT_INVALID_JSON", + }); + } + throw error; + } + }; + return locked ? read() : withStateLock(settingsPath, read); +} + +export async function writeConnectionSnapshot({ + settingsPath, + snapshot, + path = resolveConnectionSnapshotPath(settingsPath), + locked = false, +} = {}) { + const write = async () => { + const normalized = validateConnectionSnapshot(snapshot); + const payload = `${JSON.stringify(normalized, null, 2)}\n`; + if (Buffer.byteLength(payload) > MAX_CONNECTION_SNAPSHOT_BYTES) { + throw Object.assign(new Error("Connection snapshot is too large."), { + code: "CONNECTION_SNAPSHOT_TOO_LARGE", + }); + } + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + const temporaryPath = join( + directory, + `.connections-${randomUUID()}.tmp`, + ); + try { + await writeFile(temporaryPath, payload, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(temporaryPath, path); + await chmod(path, 0o600); + } catch (error) { + try { + await unlink(temporaryPath); + } catch { + /* original error is more useful */ + } + throw error; + } + return normalized; + }; + return locked ? write() : withStateLock(settingsPath, write); +} diff --git a/src/server/opencode-cli.js b/src/server/opencode-cli.js index 54abdcf..1edaacc 100644 --- a/src/server/opencode-cli.js +++ b/src/server/opencode-cli.js @@ -337,9 +337,9 @@ export function mergeDiscoveredCatalog( pricing: retainCliConflict(publicPricing, prior.pricing), capabilities: { ...prior.capabilities, - supplemental: publicPricing.reasons.includes( - "identity-conflict", - ) + supplemental: + publicPricing.reasons.includes("identity-conflict") || + publicPricing.reasons.includes("public-price-route-mismatch") ? null : (publicMetadata.models?.[id]?.capabilities ?? null), }, @@ -462,6 +462,7 @@ export function mergeDiscoveredCatalog( ); const supplemental = pricing.reasons.includes("identity-conflict") || + pricing.reasons.includes("public-price-route-mismatch") || pricing.reasons.includes("identity-or-rate-conflict") ? null : (publicMetadata?.models?.[id]?.capabilities ?? diff --git a/src/server/opencode-usage.js b/src/server/opencode-usage.js index 9ed1f8f..4f97c9c 100644 --- a/src/server/opencode-usage.js +++ b/src/server/opencode-usage.js @@ -14,9 +14,11 @@ const MAX_MODEL_ROWS = 250; const PROVIDER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u; const MODEL_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:+/-]*$/u; const CAVEATS = Object.freeze([ - "Token and cost values are recorded by OpenCode from provider responses; zero values may mean the provider did not report usage.", - "Recorded cost is not a provider bill.", + "Token counts are usage. OpenCode-recorded cost is not a provider bill or subscription charge.", + "Missing cost or token fields stay unreported instead of becoming zero.", + "Historical OpenCode totals are not classified by today's login or billing mode.", "Cache-read tokens are cumulative usage, not the current context size.", + "Plan quota is not reported unless a supported host adapter exposes it.", ]); function usageError(code, message, statusCode) { @@ -64,30 +66,36 @@ export function usageSqlForWindow(input = DEFAULT_USAGE_WINDOW) { THEN json_extract(message.data, '$.modelID') END AS model_id, CASE WHEN json_type(message.data, '$.cost') IN ('integer', 'real') AND json_extract(message.data, '$.cost') >= 0 - THEN json_extract(message.data, '$.cost') ELSE 0 END AS cost_usd, + THEN json_extract(message.data, '$.cost') END AS cost_usd, CASE WHEN json_type(message.data, '$.tokens.input') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.input') >= 0 - THEN json_extract(message.data, '$.tokens.input') ELSE 0 END AS tokens_input, + THEN json_extract(message.data, '$.tokens.input') END AS tokens_input, CASE WHEN json_type(message.data, '$.tokens.output') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.output') >= 0 - THEN json_extract(message.data, '$.tokens.output') ELSE 0 END AS tokens_output, + THEN json_extract(message.data, '$.tokens.output') END AS tokens_output, CASE WHEN json_type(message.data, '$.tokens.reasoning') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.reasoning') >= 0 - THEN json_extract(message.data, '$.tokens.reasoning') ELSE 0 END AS tokens_reasoning, + THEN json_extract(message.data, '$.tokens.reasoning') END AS tokens_reasoning, CASE WHEN json_type(message.data, '$.tokens.cache.read') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.cache.read') >= 0 - THEN json_extract(message.data, '$.tokens.cache.read') ELSE 0 END AS tokens_cache_read, + THEN json_extract(message.data, '$.tokens.cache.read') END AS tokens_cache_read, CASE WHEN json_type(message.data, '$.tokens.cache.write') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.cache.write') >= 0 - THEN json_extract(message.data, '$.tokens.cache.write') ELSE 0 END AS tokens_cache_write, + THEN json_extract(message.data, '$.tokens.cache.write') END AS tokens_cache_write, CASE WHEN - json_type(message.data, '$.cost') IN ('integer', 'real') AND json_extract(message.data, '$.cost') >= 0 AND - json_type(message.data, '$.tokens.input') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.input') >= 0 AND - json_type(message.data, '$.tokens.output') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.output') >= 0 AND - json_type(message.data, '$.tokens.reasoning') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.reasoning') >= 0 AND - json_type(message.data, '$.tokens.cache.read') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.cache.read') >= 0 AND - json_type(message.data, '$.tokens.cache.write') IN ('integer', 'real') AND json_extract(message.data, '$.tokens.cache.write') >= 0 - THEN 0 ELSE 1 END AS invalid_accounting + (json_type(message.data, '$.cost') IS NOT NULL AND json_type(message.data, '$.cost') != 'null' + AND (json_type(message.data, '$.cost') NOT IN ('integer', 'real') OR json_extract(message.data, '$.cost') < 0)) + OR (json_type(message.data, '$.tokens.input') IS NOT NULL AND json_type(message.data, '$.tokens.input') != 'null' + AND (json_type(message.data, '$.tokens.input') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.input') < 0)) + OR (json_type(message.data, '$.tokens.output') IS NOT NULL AND json_type(message.data, '$.tokens.output') != 'null' + AND (json_type(message.data, '$.tokens.output') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.output') < 0)) + OR (json_type(message.data, '$.tokens.reasoning') IS NOT NULL AND json_type(message.data, '$.tokens.reasoning') != 'null' + AND (json_type(message.data, '$.tokens.reasoning') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.reasoning') < 0)) + OR (json_type(message.data, '$.tokens.cache.read') IS NOT NULL AND json_type(message.data, '$.tokens.cache.read') != 'null' + AND (json_type(message.data, '$.tokens.cache.read') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.cache.read') < 0)) + OR (json_type(message.data, '$.tokens.cache.write') IS NOT NULL AND json_type(message.data, '$.tokens.cache.write') != 'null' + AND (json_type(message.data, '$.tokens.cache.write') NOT IN ('integer', 'real') OR json_extract(message.data, '$.tokens.cache.write') < 0)) + THEN 1 ELSE 0 END AS invalid_accounting FROM message WHERE json_extract(message.data, '$.role') = 'assistant' AND ${windowFilter(window)} @@ -97,15 +105,15 @@ export function usageSqlForWindow(input = DEFAULT_USAGE_WINDOW) { model_id, COUNT(DISTINCT session_id) AS sessions, COUNT(*) AS messages, - COALESCE(SUM(cost_usd), 0) AS cost_usd, - COALESCE(SUM(tokens_input), 0) AS tokens_input, - COALESCE(SUM(tokens_output), 0) AS tokens_output, - COALESCE(SUM(tokens_reasoning), 0) AS tokens_reasoning, - COALESCE(SUM(tokens_cache_read), 0) AS tokens_cache_read, - COALESCE(SUM(tokens_cache_write), 0) AS tokens_cache_write, + SUM(cost_usd) AS cost_usd, + SUM(tokens_input) AS tokens_input, + SUM(tokens_output) AS tokens_output, + SUM(tokens_reasoning) AS tokens_reasoning, + SUM(tokens_cache_read) AS tokens_cache_read, + SUM(tokens_cache_write) AS tokens_cache_write, MIN(time_created) AS earliest, MAX(time_created) AS latest, - SUM(CASE WHEN tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write = 0 THEN 1 ELSE 0 END) AS zero_token_messages + SUM(CASE WHEN tokens_input IS NOT NULL AND tokens_output IS NOT NULL AND COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) + COALESCE(tokens_reasoning, 0) + COALESCE(tokens_cache_read, 0) + COALESCE(tokens_cache_write, 0) = 0 THEN 1 ELSE 0 END) AS zero_token_messages FROM filtered WHERE provider_id IS NOT NULL AND model_id IS NOT NULL GROUP BY provider_id, model_id @@ -120,18 +128,18 @@ SELECT NULL AS model_id, COUNT(DISTINCT session_id) AS sessions, COUNT(*) AS messages, - COALESCE(SUM(cost_usd), 0) AS cost_usd, - COALESCE(SUM(tokens_input), 0) AS tokens_input, - COALESCE(SUM(tokens_output), 0) AS tokens_output, - COALESCE(SUM(tokens_reasoning), 0) AS tokens_reasoning, - COALESCE(SUM(tokens_cache_read), 0) AS tokens_cache_read, - COALESCE(SUM(tokens_cache_write), 0) AS tokens_cache_write, + SUM(cost_usd) AS cost_usd, + SUM(tokens_input) AS tokens_input, + SUM(tokens_output) AS tokens_output, + SUM(tokens_reasoning) AS tokens_reasoning, + SUM(tokens_cache_read) AS tokens_cache_read, + SUM(tokens_cache_write) AS tokens_cache_write, MIN(time_created) AS earliest, MAX(time_created) AS latest, COUNT(DISTINCT CASE WHEN provider_id IS NOT NULL AND model_id IS NOT NULL THEN provider_id || char(0) || model_id END) AS model_count, COALESCE(SUM(CASE WHEN provider_id IS NULL OR model_id IS NULL THEN 1 ELSE 0 END), 0) AS unattributed_messages, - COALESCE(SUM(CASE WHEN tokens_input + tokens_output + tokens_reasoning + tokens_cache_read + tokens_cache_write = 0 THEN 1 ELSE 0 END), 0) AS zero_token_messages, + COALESCE(SUM(CASE WHEN tokens_input IS NOT NULL AND tokens_output IS NOT NULL AND COALESCE(tokens_input, 0) + COALESCE(tokens_output, 0) + COALESCE(tokens_reasoning, 0) + COALESCE(tokens_cache_read, 0) + COALESCE(tokens_cache_write, 0) = 0 THEN 1 ELSE 0 END), 0) AS zero_token_messages, COALESCE(SUM(invalid_accounting), 0) AS invalid_accounting_messages FROM filtered UNION ALL @@ -156,7 +164,11 @@ SELECT FROM model_usage`; } -function finiteNumber(value, field, { integer = false } = {}) { +function finiteNumber(value, field, { integer = false, optional = false } = {}) { + if (value === null || value === undefined) { + if (optional) return null; + throw usageError("OPENCODE_USAGE_INVALID", `OpenCode returned invalid ${field} usage.`, 502); + } if (typeof value !== "number" || !Number.isFinite(value) || value < 0) { throw usageError("OPENCODE_USAGE_INVALID", `OpenCode returned invalid ${field} usage.`, 502); } @@ -178,14 +190,15 @@ function timestamp(value, field) { function tokenCounts(row) { const tokens = { - input: finiteNumber(row.tokens_input, "input token", { integer: true }), - output: finiteNumber(row.tokens_output, "output token", { integer: true }), - reasoning: finiteNumber(row.tokens_reasoning, "reasoning token", { integer: true }), - cacheRead: finiteNumber(row.tokens_cache_read, "cache-read token", { integer: true }), - cacheWrite: finiteNumber(row.tokens_cache_write, "cache-write token", { integer: true }), + input: finiteNumber(row.tokens_input, "input token", { integer: true, optional: true }), + output: finiteNumber(row.tokens_output, "output token", { integer: true, optional: true }), + reasoning: finiteNumber(row.tokens_reasoning, "reasoning token", { integer: true, optional: true }), + cacheRead: finiteNumber(row.tokens_cache_read, "cache-read token", { integer: true, optional: true }), + cacheWrite: finiteNumber(row.tokens_cache_write, "cache-write token", { integer: true, optional: true }), }; - const total = Object.values(tokens).reduce((sum, value) => sum + value, 0); - if (!Number.isSafeInteger(total)) { + const present = Object.values(tokens).filter((value) => value !== null); + const total = present.length ? present.reduce((sum, value) => sum + value, 0) : null; + if (total !== null && !Number.isSafeInteger(total)) { throw usageError("OPENCODE_USAGE_INVALID", "OpenCode returned an unsafe token total.", 502); } return { ...tokens, total }; @@ -236,7 +249,7 @@ export function parseOpenCodeUsageRows(stdout, { const totals = { sessions: finiteNumber(summary.sessions, "session", { integer: true }), messages: finiteNumber(summary.messages, "message", { integer: true }), - costUsd: finiteNumber(summary.cost_usd, "cost"), + costUsd: finiteNumber(summary.cost_usd, "cost", { optional: true }), tokens: tokenCounts(summary), }; const modelsSeen = finiteNumber(summary.model_count, "model", { integer: true }); @@ -255,7 +268,7 @@ export function parseOpenCodeUsageRows(stdout, { modelId, sessions: finiteNumber(row.sessions, "model session", { integer: true }), messages: finiteNumber(row.messages, "model message", { integer: true }), - costUsd: finiteNumber(row.cost_usd, "model cost"), + costUsd: finiteNumber(row.cost_usd, "model cost", { optional: true }), tokens: tokenCounts(row), }; }).sort((left, right) => { @@ -271,9 +284,10 @@ export function parseOpenCodeUsageRows(stdout, { } return { - schemaVersion: 1, + schemaVersion: 2, source: "opencode-local-accounting", - accounting: "provider-reported", + accounting: "opencode-recorded", + costLabel: "OpenCode-recorded cost", window, windowDays: USAGE_WINDOWS[window], generatedAt: generated.toISOString(), @@ -296,6 +310,15 @@ export function parseOpenCodeUsageRows(stdout, { earliestMessageAt: timestamp(summary.earliest, "earliest timestamp"), latestMessageAt: timestamp(summary.latest, "latest timestamp"), }, + quota: { status: "not-reported" }, + attributed: { + observations: [], + coverage: { + firstObservedAt: null, + droppedCount: 0, + truncated: false, + }, + }, caveats: [...CAVEATS], }; } diff --git a/src/server/service.js b/src/server/service.js index 96c7752..cc2fe6f 100644 --- a/src/server/service.js +++ b/src/server/service.js @@ -5,6 +5,7 @@ import { modelSupports, modelEnabled, eligibleModelsForRole, + resolveEligibility, assertExplicitAssignments, CATALOG_REFRESH_MS, planRoute, @@ -24,6 +25,7 @@ import { import { classifyRouteRequest } from "./task-classifier.js"; import { discoverOpenCode, mergeDiscoveredCatalog } from "./opencode-cli.js"; import { readOpenCodeUsage } from "./opencode-usage.js"; +import { readUsageAttribution } from "./usage-attribution-store.js"; import { runOpenCodeRuntimeQualification } from "./runtime-qualification.js"; import { appendRuntimeQualificationResult, @@ -37,6 +39,15 @@ import { settingsConflict, } from "./settings-store.js"; import { readControlSnapshot, writeRefreshStatus } from "./state-snapshot.js"; +import { + readConnectionSnapshot, + writeConnectionSnapshot, +} from "./connection-store.js"; +import { + observeConnections, + providersFromLiveModels, +} from "../opencode/connection-observer.js"; +import { applyBillingDeclarations } from "../core/connections.js"; import { acquireFileLock, withStateLock } from "./state-lock.js"; import { readModelsDevCache, @@ -96,21 +107,29 @@ function validateRuntimeQualificationRequest(input) { return input.modelId.trim(); } -function modelBlockReasons(model, settings) { +function modelBlockReasons(model, settings, connections, role = null) { const reasons = []; if (!modelEnabled(settings, model.id)) reasons.push("disabled"); if (!model.available || settings.modelControls[model.id]?.available === false) reasons.push("unavailable"); - const pricing = classifyModelPricing(model); - if (pricing === "unknown") reasons.push("unknown-pricing"); - else if (pricing === "paid" && settings.costPolicy === "free-only") - reasons.push("paid-blocked"); + const providerId = model.id.slice(0, model.id.indexOf("/")); + const connection = Array.isArray(connections) + ? connections.find((item) => item.providerId === providerId) ?? null + : null; + const eligibility = resolveEligibility({ + model, + settings, + connection, + connections, + role, + }); + reasons.push(...eligibility.blockingReasons); return reasons; } -function publicCatalog(catalog, settings) { +function publicCatalog(catalog, settings, connections) { return catalog.models.map((model) => { - const blockedReasons = modelBlockReasons(model, settings); + const blockedReasons = modelBlockReasons(model, settings, connections); return { ...model, displayName: model.label, @@ -129,7 +148,7 @@ function publicCatalog(catalog, settings) { }); } -function blockedRoles(catalog, settings) { +function blockedRoles(catalog, settings, connections) { return Object.fromEntries( Object.entries(ROLE_REQUIREMENTS).map(([role, requirement]) => { const selected = settings.roleAssignments[role]; @@ -139,12 +158,13 @@ function blockedRoles(catalog, settings) { role, modalities: [...requirement.modalities], access: requirement.access, + connections, }); if (selected === "auto") return [role, eligible.length ? [] : ["no-eligible-model"]]; const model = catalog.models.find((m) => m.id === selected); if (!model) return [role, ["unavailable"]]; - const reasons = modelBlockReasons(model, settings); + const reasons = modelBlockReasons(model, settings, connections, role); if ( !modelSupports({ model, @@ -205,6 +225,7 @@ export class ControlService { this.runtimeQualificationRunner = runtimeQualificationRunner; this.baseCatalog = loadModelCatalog(); this.catalog = unavailableCatalog(this.baseCatalog); + this.connections = []; this.hasLiveSnapshot = false; this.settings = createDefaultSettings(this.catalog); this.openCode = { @@ -251,10 +272,15 @@ export class ControlService { getState() { return { - schemaVersion: 3, + schemaVersion: 4, settingsRevision: this.settingsRevision, catalogRevision: this.catalog.revision, - blockedRoles: blockedRoles(this.catalog, this.settings), + connectionRevision: this.connectionRevision, + blockedRoles: blockedRoles( + this.catalog, + this.settings, + this.connections, + ), system: { localOnly: true, freeOnly: this.settings.costPolicy === "free-only", @@ -289,7 +315,8 @@ export class ControlService { .join(" ") || null, }, }, - catalog: publicCatalog(this.catalog, this.settings), + catalog: publicCatalog(this.catalog, this.settings, this.connections), + connections: this.connections, settings: this.settings, }; } @@ -378,6 +405,25 @@ export class ControlService { this.catalog = await writeCatalogSnapshot(merged, { path: this.catalogSnapshotPath, }); + const previousConnections = await readConnectionSnapshot({ + settingsPath: this.settingsPath, + locked: true, + }); + const observedConnections = observeConnections({ + providers: providersFromLiveModels(models), + previousConnections: previousConnections.connections, + scopeId: previousConnections.scopeId, + now: this.now(), + }); + const retainedConnections = discovered.complete === true ? [] : + previousConnections.connections.filter(prior => !observedConnections.some(item => item.id === prior.id)); + const savedConnections = await writeConnectionSnapshot({ + settingsPath: this.settingsPath, locked: true, + snapshot: { schemaVersion: 1, scopeId: previousConnections.scopeId, + connections: [...observedConnections, ...retainedConnections] }, + }); + this.connections = applyBillingDeclarations(savedConnections.connections, this.settings.billingDeclarations); + this.connectionRevision = savedConnections.revision; const complete = discovered.complete === true; this.refreshState = { attemptedAt, @@ -424,6 +470,8 @@ export class ControlService { }); this.catalog = snapshot.catalog; this.settings = snapshot.settings; + this.connections = snapshot.connections ?? []; + this.connectionRevision = snapshot.connectionRevision; this.settingsRevision = snapshot.settingsRevision; this.refreshState = snapshot.refresh; this.hasLiveSnapshot = Boolean(snapshot.refresh); @@ -438,7 +486,7 @@ export class ControlService { async updateSettings( input, - { expectedSettingsRevision, catalogRevision } = {}, + { expectedSettingsRevision, catalogRevision, expectedConnectionRevision } = {}, ) { if (typeof expectedSettingsRevision !== "string") throw settingsConflict("SETTINGS_REVISION_REQUIRED", [ @@ -455,13 +503,52 @@ export class ControlService { error.statusCode = 400; throw error; } + const changed = (a, b) => JSON.stringify(a ?? null) !== JSON.stringify(b ?? null); + const newlyPinnedRoles = Object.keys(settings.roleAssignments).filter(role => + settings.roleAssignments[role] !== "auto" && settings.roleAssignments[role] !== this.settings.roleAssignments[role]); + const editedBindings = Object.keys(settings.roleConnections).filter(role => + changed(settings.roleConnections[role], this.settings.roleConnections[role])); + const declarationIds = new Set([...Object.keys(settings.billingDeclarations), ...Object.keys(this.settings.billingDeclarations)]); + const editedDeclarations = [...declarationIds].filter(id => + changed(settings.billingDeclarations[id], this.settings.billingDeclarations[id])); + if (editedBindings.length || editedDeclarations.length || newlyPinnedRoles.length) { + if (typeof expectedConnectionRevision !== "string") + throw settingsConflict("CONNECTION_REVISION_REQUIRED", ["Reload connection metadata before editing billing or connection assignments."]); + if (expectedConnectionRevision !== this.connectionRevision) + throw settingsConflict("CONNECTION_CONFLICT", ["Connections changed while you were editing. Review the latest connection details and retry; your draft is retained."]); + for (const role of new Set([...editedBindings, ...newlyPinnedRoles])) { + const binding = settings.roleConnections[role]; + const modelId = settings.roleAssignments[role]; + if (!binding && modelId === "auto") continue; + if (!binding) + throw settingsConflict("CONNECTION_CONFLICT", ["A new explicit model assignment requires its current connection binding. Review the connection before saving."]); + const connection = this.connections.find(item => item.id === binding.connectionId); + if (!connection || connection.bindingRevision !== binding.bindingRevision || + modelId === "auto" || connection.providerId !== modelId.split("/")[0]) + throw settingsConflict("CONNECTION_CONFLICT", ["The selected model and connection no longer match. Review the connection before saving."]); + } + for (const id of editedDeclarations) { + const declaration = settings.billingDeclarations[id]; + if (!declaration) continue; + const connection = this.connections.find(item => item.id === id); + if (!connection || declaration.bindingRevision !== connection.bindingRevision || + ["host", "provider-adapter"].includes(connection.billing.source)) + throw settingsConflict("CONNECTION_CONFLICT", ["Billing can only be declared for the current connection when its billing is not reported by the host."]); + } + } + const effectiveConnections = applyBillingDeclarations(this.connections, settings.billingDeclarations); const editedRoles = Object.keys(settings.roleAssignments).filter( (role) => settings.roleAssignments[role] !== - this.settings.roleAssignments[role], + this.settings.roleAssignments[role] || editedBindings.includes(role), ); try { - assertExplicitAssignments(settings, this.catalog, editedRoles); + assertExplicitAssignments( + settings, + this.catalog, + editedRoles, + effectiveConnections, + ); for (const [id, control] of Object.entries(settings.modelControls)) { if ( control.selection !== "enabled" || @@ -469,7 +556,7 @@ export class ControlService { ) continue; const model = this.catalog.models.find((model) => model.id === id); - if (!model || modelBlockReasons(model, settings).length) + if (!model || modelBlockReasons(model, settings, effectiveConnections).length) throw new Error( "The edited model selection is not currently eligible.", ); @@ -500,6 +587,7 @@ export class ControlService { task, catalog: this.catalog, settings: this.settings, + connections: this.connections, }); const integrationWarning = input?.modality && input.modality !== "text" @@ -511,6 +599,7 @@ export class ControlService { integrationWarning, settingsRevision: this.settingsRevision, catalogRevision: this.catalog.revision, + connectionRevision: this.connectionRevision, }; } @@ -527,7 +616,7 @@ export class ControlService { warnings: [ "Connect manages only the model-control MCP, omc-* agents, its exact plugin array item, and an optional receipt-owned default_agent. Conflicting or user-owned values are never overwritten.", "The preview shows the requested default_agent entry. Connect omits it when OpenCode already has a user-owned default.", - "The bundled local plugin applies saved policy to every owned OMC role on its next request. Media-only turns stay read-only, and unavailable or unknown-cost routes fail closed.", + "The bundled local plugin applies saved policy to every owned OMC role on its next request. Media-only turns stay read-only. Free routing requires verified zero pricing; configured Paid routing requires an eligible host connection.", ], }; } @@ -618,7 +707,29 @@ export class ControlService { } async getUsage(window) { - return this.usageReader({ window }); + const usage = await this.usageReader({ window }); + try { + usage.attributed = await readUsageAttribution({ + settingsPath: this.settingsPath, + from: window === "all" ? undefined : new Date(this.now() - ({"7d": 7, "30d": 30, "90d": 90}[window] ?? 30) * 86400000).toISOString(), + to: new Date(this.now()).toISOString(), + now: this.now(), + }); + } catch { + usage.attributed = { + observations: [], + coverage: { + firstObservedAt: null, + droppedCount: 0, + truncated: true, + pendingCount: 0, + failedWriteCount: 0, + partial: true, + lastFailureCode: "ATTRIBUTION_READ_FAILED", + }, + }; + } + return usage; } async getOpenCodeIntegration() { diff --git a/src/server/state-snapshot.js b/src/server/state-snapshot.js index 92ec7bb..04029dd 100644 --- a/src/server/state-snapshot.js +++ b/src/server/state-snapshot.js @@ -11,6 +11,8 @@ import { readCatalogSnapshot, resolveCatalogSnapshotPath, } from "./catalog-store.js"; +import { readConnectionSnapshot } from "./connection-store.js"; +import { applyBillingDeclarations } from "../core/connections.js"; import { withStateLock } from "./state-lock.js"; export const refreshStatusPath = (settingsPath) => @@ -79,9 +81,16 @@ export async function readControlSnapshot({ locked: true, }); const raw = (await readRawSettings(settingsPath)).value; + const connections = await readConnectionSnapshot({ + settingsPath, + locked: true, + }); return { catalog, settings, + connections: applyBillingDeclarations(connections.connections, settings.billingDeclarations), + connectionScopeId: connections.scopeId, + connectionRevision: connections.revision, settingsExists: raw !== undefined, catalogExists: savedCatalog !== null, settingsRevision: settingsRevision(raw), diff --git a/src/server/usage-attribution-store.js b/src/server/usage-attribution-store.js new file mode 100644 index 0000000..179c2e5 --- /dev/null +++ b/src/server/usage-attribution-store.js @@ -0,0 +1,266 @@ +import { createHmac, randomBytes, createHash } from "node:crypto"; +import { + chmod, + lstat, + mkdir, + readFile, + rename, + unlink, + writeFile, +} from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { validateUsageObservation } from "../core/usage-accounting.js"; +import { withStateLock } from "./state-lock.js"; + +const MAX_RECORDS = 10_000; +const MAX_BYTES = 10 * 1024 * 1024; +const MAX_PENDING = 1_000; +const RETENTION_MS = 90 * 24 * 60 * 60 * 1000; +const MAX_FILE_BYTES = MAX_BYTES; + +export function resolveAttributionPath(settingsPath) { + return join(dirname(settingsPath), "usage-attribution.json"); +} + +function resolveSaltPath(settingsPath) { + return join(dirname(settingsPath), "usage-attribution.salt"); +} + +export async function readOrCreateAttributionSalt(settingsPath) { + return withStateLock(settingsPath, () => createSalt(settingsPath)); +} + +async function createSalt(settingsPath) { + const path = resolveSaltPath(settingsPath); + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink() || metadata.size !== 32) throw new Error("Attribution salt is invalid."); + const salt = await readFile(path); + if (salt.length === 32) { await chmod(path, 0o600); return salt; } + throw new Error("Attribution salt is invalid."); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + const salt = randomBytes(32); + await mkdir(dirname(path), { recursive: true, mode: 0o700 }); + await chmod(dirname(path), 0o700); + const temporary = `${path}.${randomBytes(8).toString("hex")}.tmp`; + try { + await writeFile(temporary, salt, { flag: "wx", mode: 0o600 }); + await rename(temporary, path); + await chmod(path, 0o600); + return salt; + } finally { + await unlink(temporary).catch(() => {}); + } +} + +export function attributionEventKey(salt, sessionId, messageId) { + return createHmac("sha256", salt) + .update(`${sessionId}\0${messageId}`) + .digest("hex"); +} + +function emptyStore() { + return { + schemaVersion: 1, + observations: [], + pending: [], + droppedCount: 0, + truncated: false, + failedWriteCount: 0, + lastFailureCode: null, + }; +} + +async function readStore(path) { + try { + const metadata = await lstat(path); + if (!metadata.isFile() || metadata.isSymbolicLink()) { + throw Object.assign(new Error("Usage attribution must be a regular file."), { + code: "USAGE_ATTRIBUTION_INVALID", + }); + } + if (metadata.size > MAX_FILE_BYTES) { + throw Object.assign(new Error("Usage attribution is too large."), { + code: "USAGE_ATTRIBUTION_TOO_LARGE", + }); + } + const value = JSON.parse(await readFile(path, "utf8")); + if (!value || value.schemaVersion !== 1 || !Array.isArray(value.observations)) + return { ...emptyStore(), droppedCount: 1, truncated: true }; + return { + schemaVersion: 1, + observations: validateRows(value.observations), + pending: validateRows(Array.isArray(value.pending) ? value.pending : []), + failedWriteCount: safeCount(value.failedWriteCount), + diagnosticSessions: sanitizeSessions(value.diagnosticSessions), + lastFailureCode: value.lastFailureCode === "ATTRIBUTION_WRITE_FAILED" ? value.lastFailureCode : null, + droppedCount: safeCount(value.droppedCount), + truncated: value.truncated === true, + }; + } catch (error) { + if (error?.code === "ENOENT") return emptyStore(); + if (error instanceof SyntaxError) { + throw Object.assign(new Error("Usage attribution is not valid JSON."), { + code: "USAGE_ATTRIBUTION_INVALID_JSON", + }); + } + throw error; + } +} + +function prune(store, now) { + const cutoff = now - RETENTION_MS; + let observations = store.observations.filter( + (item) => Date.parse(item.observedAt) >= cutoff, + ); + let droppedCount = store.droppedCount + (store.observations.length - observations.length); + if (observations.length > MAX_RECORDS) { + droppedCount += observations.length - MAX_RECORDS; + observations = observations.slice(observations.length - MAX_RECORDS); + } + let pending = store.pending.filter(item => Date.parse(item.observedAt) >= cutoff); + droppedCount += store.pending.length - pending.length; + if (pending.length > MAX_PENDING) { + droppedCount += pending.length - MAX_PENDING; + pending = pending.slice(pending.length - MAX_PENDING); + } + return { + ...store, + schemaVersion: 1, + observations, + pending, + droppedCount, + truncated: store.truncated || droppedCount > store.droppedCount, + }; +} + +async function writeStore(path, store) { + while (Buffer.byteLength(`${JSON.stringify(store)}\n`) > MAX_FILE_BYTES) { + if (store.observations.length) store.observations.shift(); + else if (store.pending.length) store.pending.shift(); + else throw new Error("Usage attribution is too large."); + store.truncated = true; + store.droppedCount++; + } + const directory = dirname(path); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + const temporary = join(directory, `.usage-attribution-${randomBytes(8).toString("hex")}.tmp`); + try { + await writeFile(temporary, `${JSON.stringify(store)}\n`, { + encoding: "utf8", + flag: "wx", + mode: 0o600, + }); + await rename(temporary, path); + await chmod(path, 0o600); + } catch (error) { + try { + await unlink(temporary); + } catch { + /* original error is more useful */ + } + throw error; + } +} + +export async function upsertUsageObservation({ + settingsPath, + observation, + pending = false, + pendingEventKey = null, + now = Date.now(), +} = {}) { + const path = resolveAttributionPath(settingsPath); + return withStateLock(settingsPath, async () => { + const normalized = validateUsageObservation(observation); + normalized.priceSnapshotId = normalized.priceSnapshot ? digestPriceSnapshot(normalized.priceSnapshot) : null; + const store = prune(await readStore(path), now); + const list = pending ? store.pending : store.observations; + const index = list.findIndex((item) => item.eventKey === normalized.eventKey); + // Completed assistant messages are immutable and idempotent. + if (index < 0 && (!pending || !store.observations.some(item => item.eventKey === normalized.eventKey))) list.push(normalized); + if (!pending) { + store.pending = store.pending.filter((item) => item.eventKey !== normalized.eventKey && item.eventKey !== pendingEventKey); + } + await writeStore(path, prune(store, now)); + }); +} + +export async function readUsageAttribution({ + settingsPath, + from, + to, + now = Date.now(), +} = {}) { + const path = resolveAttributionPath(settingsPath); + return withStateLock(settingsPath, async () => { + const store = prune(await readStore(path), now); + const start = from ? Date.parse(from) : 0; + const end = to ? Date.parse(to) : Number.POSITIVE_INFINITY; + const diagnostics = combinedDiagnostics(settingsPath, store); + const observations = store.observations.filter((item) => { + const at = Date.parse(item.observedAt); + return at >= start && at <= end; + }); + return { + observations, + coverage: { + firstObservedAt: observations[0]?.observedAt ?? null, + droppedCount: store.droppedCount + (diagnostics.droppedCount ?? 0), + truncated: store.truncated || (diagnostics.droppedCount ?? 0) > 0, + pendingCount: store.pending.length + (diagnostics.pendingCount ?? 0), + failedWriteCount: (store.failedWriteCount + (diagnostics.failedWriteCount ?? 0)), + partial: store.truncated || store.pending.length > 0 || (diagnostics.pendingCount ?? 0) > 0 || (store.failedWriteCount + (diagnostics.failedWriteCount ?? 0)) > 0 || (diagnostics.droppedCount ?? 0) > 0, + lastFailureCode: diagnostics.lastFailureCode ?? store.lastFailureCode, + }, + }; + }); +} + +export function digestPriceSnapshot(snapshot) { + return createHash("sha256").update(JSON.stringify(snapshot ?? null)).digest("hex"); +} + +function safeCount(value) { return Number.isSafeInteger(value) && value >= 0 ? value : 0; } +function validateRows(rows) { return rows.map(row => { + const normalized = validateUsageObservation(row); + if (normalized.priceSnapshot && normalized.priceSnapshotId !== digestPriceSnapshot(normalized.priceSnapshot)) throw new Error("Usage price evidence is invalid."); + return normalized; +}); } +const liveDiagnostics = new Map(); +function sanitizeSessions(value) { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return Object.fromEntries(Object.entries(value).filter(([id]) => /^[a-f0-9-]{36}$/.test(id)).slice(-1000).map(([id, counts]) => [id, { failedWriteCount: safeCount(counts?.failedWriteCount), droppedCount: safeCount(counts?.droppedCount) }])); +} +export function setAttributionDiagnostics(settingsPath, diagnostics) { + if (!liveDiagnostics.has(settingsPath) && liveDiagnostics.size >= 1000) liveDiagnostics.delete(liveDiagnostics.keys().next().value); + const sessions = liveDiagnostics.get(settingsPath) ?? new Map(); + if (!sessions.has(diagnostics.queueId) && sessions.size >= 1000) sessions.delete(sessions.keys().next().value); + sessions.set(diagnostics.queueId, diagnostics); + liveDiagnostics.set(settingsPath, sessions); +} +function combinedDiagnostics(settingsPath, store) { + const total = { pendingCount: 0, failedWriteCount: 0, droppedCount: 0, lastFailureCode: null }; + for (const [id, state] of liveDiagnostics.get(settingsPath) ?? []) { + const persisted = store.diagnosticSessions?.[id] ?? {}; + total.pendingCount += state.pendingCount; + for (const key of ["failedWriteCount", "droppedCount"]) total[key] += Math.max(0, state[key] - safeCount(persisted[key])); + total.lastFailureCode = state.lastFailureCode ?? total.lastFailureCode; + } + return total; +} +export async function persistAttributionDiagnostics(settingsPath, diagnostics) { + return withStateLock(settingsPath, async () => { + const path = resolveAttributionPath(settingsPath); + const store = prune(await readStore(path), Date.now()); + const previous = store.diagnosticSessions?.[diagnostics.queueId] ?? {}; + for (const key of ["failedWriteCount", "droppedCount"]) store[key] += Math.max(0, safeCount(diagnostics[key]) - safeCount(previous[key])); + store.diagnosticSessions = sanitizeSessions({ ...store.diagnosticSessions, [diagnostics.queueId]: diagnostics }); + store.lastFailureCode = diagnostics.lastFailureCode ?? store.lastFailureCode; + store.truncated ||= diagnostics.droppedCount > 0; + await writeStore(path, store); + }); +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 6695ec2..3fcc879 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -25,6 +25,7 @@ import { AppShell } from "./components/AppShell"; import { BenchmarkPanel } from "./components/BenchmarkPanel"; import { ConfigPanel } from "./components/ConfigPanel"; import { ModelTable } from "./components/ModelTable"; +import { ConnectionsPanel } from "./components/ConnectionsPanel"; import { RoleAssignments } from "./components/RoleAssignments"; import { RouteTester } from "./components/RouteTester"; import { RoutingOverview } from "./components/RoutingOverview"; @@ -216,7 +217,7 @@ export default function App() { setSaving(true); setActionError(""); setNotice(""); try { - const result = await updateSettings(settingsForApi(current.draft) as RouterSettings, current.baselineRevision, current.state.catalogRevision); + const result = await updateSettings(settingsForApi(current.draft) as RouterSettings, current.baselineRevision, current.state.catalogRevision, current.baselineConnectionRevision); publishEditor(finishSave(editorRef.current, result, requestId)); setConflict(false); setNotice(current.baseline.makeRouterDefault !== current.draft.makeRouterDefault @@ -324,7 +325,7 @@ export default function App() {
Control plane {localOnly ? "local-only" : "not confirmed local"}
-
{paidAllowed ? "Paid models allowed" : "Verified free only"}
+
{paidAllowed ? (draftSettings?.paidEligibility === "configured-connections" ? "Configured paid connections" : "Legacy Paid") : "Verified free only"}
{dirty ? Unsaved changes : null} {dirty ? : null} @@ -363,6 +364,7 @@ export default function App() { qualification={runtimeQualification} settings={draftSettings} /> + { return requestJson("/api/state", { signal }); } -export function updateSettings(settings: RouterSettings, expectedSettingsRevision: string, catalogRevision: string): Promise { +export function updateSettings(settings: RouterSettings, expectedSettingsRevision: string, catalogRevision: string, expectedConnectionRevision: string): Promise { return requestJson("/api/settings", { method: "PUT", - body: JSON.stringify({settings, expectedSettingsRevision, catalogRevision}), + body: JSON.stringify({settings, expectedSettingsRevision, catalogRevision, expectedConnectionRevision}), }); } diff --git a/src/ui/components/ConnectionsPanel.tsx b/src/ui/components/ConnectionsPanel.tsx new file mode 100644 index 0000000..4560a84 --- /dev/null +++ b/src/ui/components/ConnectionsPanel.tsx @@ -0,0 +1,40 @@ +import type { Connection, RouterSettings, BillingKind } from "../types"; +import { billingLabel, evidenceSourceLabel, effectiveBilling } from "../model-control.js"; +import { Panel } from "./Primitives"; + +export function ConnectionsPanel({connections, settings, onChange}: {connections: Connection[]; settings: RouterSettings; onChange: (settings: RouterSettings) => void}) { + return +

Configured connections

+

Each row is one configured OpenCode provider slot. Billing declarations describe your setup; they do not verify entitlement or free access.

+ {connections.length === 0 ?

Connection metadata not reported. Refresh the catalog to discover configured slots.

: connections.map(connection => { + const billing = effectiveBilling(connection, settings); + const declaration = settings.billingDeclarations?.[connection.id]; + const stale = declaration && declaration.bindingRevision !== connection.bindingRevision; + const editable = !["host", "provider-adapter"].includes(connection.billing.source) && (connection.billing.kind === "unknown" || connection.billing.source === "user-declared"); + const quota = connection.quota; + return
+

{connection.providerId}

+

{billingLabel(billing?.kind)} · {evidenceSourceLabel(billing?.source)}

+
+
Authentication
{({oauth: "OAuth", "api-key": "API key", none: "None", unknown: "Not reported"})[connection.authKind]}
+
Access evidence
{connection.entitlement === "reported-revoked" ? "Revoked" : connection.entitlement === "reported-active" ? "Reported active" : "Not reported"}
+
Inventory observed
{connection.inventoryObservedAt ?? "Not reported"}
+
Transport visibility
{connection.transportVisibility === "host-managed" ? "Managed by OpenCode" : "Declared endpoint"}
+
Billing observed
{billing?.observedAt ?? "Not reported"}
+
Quota
{quota ? <>{Date.parse(quota.expiresAt) <= Date.now() ? "Stale observation · " : ""}{evidenceSourceLabel(quota.source)} · {quota.unit}. Used: {quota.used ?? "Not reported"}; remaining: {quota.remaining ?? "Not reported"}; limit: {quota.limit ?? "Not reported"}. Observed: {quota.observedAt}; expires: {quota.expiresAt}; resets: {quota.resetsAt ?? "Not reported"} : "Not reported"}
+
+ {stale ?

Connection changed — previous declaration needs review.

: null} + {editable ? : null} +
; + })} +

Binding checks detect changes exposed by the host. Credential changes the host does not report cannot be detected. Quota is never calculated from token prices.

+
; +} diff --git a/src/ui/components/ModelTable.tsx b/src/ui/components/ModelTable.tsx index f385286..a8bd74a 100644 --- a/src/ui/components/ModelTable.tsx +++ b/src/ui/components/ModelTable.tsx @@ -8,6 +8,9 @@ import type { } from "../types"; import { catalogSummary, + billingLabel, + effectiveBilling, + evidenceSourceLabel, evidenceMeta, isModelAvailable, modelCostClass, @@ -201,7 +204,9 @@ export function ModelTable({ Availability Evidence Inputs and roles - Cost + Connection + Access + Pricing Enabled @@ -252,13 +257,9 @@ export function ModelTable({ - {available ? "Available" : "Unavailable"} + {billingLabel(effectiveBilling(model.connection, settings)?.kind)} · {available ? "Available" : "Unavailable"} -

- {reasons.length - ? reasons.join(" ") - : "Eligible under draft policy"} -

+ {evidenceSourceLabel(effectiveBilling(model.connection, settings)?.source)} - + + + {model.provider ?? model.id.split("/")[0]} ·{" "} + {billingLabel(effectiveBilling(model.connection, settings)?.kind)} · {available ? "Available" : "Unavailable"} + + {evidenceSourceLabel(effectiveBilling(model.connection, settings)?.source)} +

+ {model.api && + typeof model.api === "object" && + "urlValid" in model.api && + model.api.urlValid === false + ? "Invalid endpoint" + : model.pricing?.reasons?.includes( + "public-price-route-mismatch", + ) + ? "Public rates do not match this endpoint" + : "Reported by OpenCode"} +

+ + + + + {reasons.length + ? reasons[0] + : "Eligible under draft policy"} + + {reasons.length > 1 ? ( +

+ {reasons.slice(1).join(" ")} +

+ ) : null} + +
Pricing evidence diff --git a/src/ui/components/RoleAssignments.tsx b/src/ui/components/RoleAssignments.tsx index cbcaa6e..1cad66d 100644 --- a/src/ui/components/RoleAssignments.tsx +++ b/src/ui/components/RoleAssignments.tsx @@ -8,6 +8,9 @@ import { ROLE_DEFINITIONS, selectRoleModel, setCostMode, + adoptConfiguredPaid, + billingLabel, + effectiveBilling, } from "../model-control.js"; import { Icon, Panel } from "./Primitives"; @@ -61,10 +64,23 @@ export function RoleAssignments({ >Paid - {settings.autoIncludeNewModels ? "Policy-following models join automatically when eligible. In Paid mode, newly discovered known-paid models can be enrolled and incur charges. Explicit disables remain off." : "New policy-following models stay off until explicitly enabled. Returning a model to Policy uses this setting."} + {settings.autoIncludeNewModels ? "Policy-following models join automatically when eligible. In Paid mode, newly discovered configured paid models can be enrolled. Explicit disables remain off." : "New policy-following models stay off until explicitly enabled. Returning a model to Policy uses this setting."} {settings.costPolicy === "known-cost" - ? "Paid-first automatic routing is enabled. Provider charges may apply; unknown-cost models stay blocked." + ? settings.paidEligibility === "configured-connections" + ? "Allow configured paid connections, including subscriptions. Cost estimates may be unavailable." + : "Legacy Paid policy still requires verified prices. Save the new Paid option to allow configured connections." : "Only models with independently verified free pricing can be routed."} + {settings.costPolicy === "known-cost" && settings.paidEligibility !== "configured-connections" ? ( +

+ Paid used to require verified public prices. Save Paid again to allow configured connections, including subscriptions, when estimates are unavailable. + +

+ ) : null}
{ROLE_DEFINITIONS.map((role) => ( @@ -88,11 +104,12 @@ export function RoleAssignments({ : eligible ? "" : " — enable on selection"; - return ; + return ; })} {settings.roleAssignments[role.key] !== "auto" && modelEligibilityReasons(catalog.find(model => model.id === settings.roleAssignments[role.key]), settings, role.key).length > 0 ? Retained pin: {settings.roleAssignments[role.key]}. {modelEligibilityReasons(catalog.find(model => model.id === settings.roleAssignments[role.key]), settings, role.key).join(" ")} Choose Automatic or another model to replace it. : null} + {settings.roleAssignments[role.key] !== "auto" && modelEligibilityReasons(catalog.find(model => model.id === settings.roleAssignments[role.key]), settings, role.key).some(reason => reason.includes("Connection changed") || reason.includes("Connection selection required")) ? : null} {selectHint(role.key)} Selecting a disabled model explicitly enables it for routing. ))} @@ -120,7 +137,7 @@ export function RoleAssignments({ Allows one bounded return to the same code worker after a reviewer finds a concrete defect. It does not switch to another model.
-
{settings.costPolicy === "known-cost" ? "Known paid models allowed" : "Verified-free policy active"}Unknown or unverified pricing is always excluded from automatic routing.
+
{settings.costPolicy === "known-cost" ? (settings.paidEligibility === "configured-connections" ? "Configured paid connections allowed" : "Legacy verified-price Paid policy") : "Verified-free policy active"}{settings.costPolicy !== "known-cost" ? "Unknown or unverified pricing cannot authorize Free routing." : settings.paidEligibility === "configured-connections" ? "Missing estimates do not block a configured host route." : "Unknown public prices stay blocked until you adopt configured Paid access."}
{enabledModels.length === 0 ?

No models are enabled yet. Select a compatible model above or enable one in Models.

: null} ); diff --git a/src/ui/components/UsagePanel.tsx b/src/ui/components/UsagePanel.tsx index b8f5c98..0f3c8c7 100644 --- a/src/ui/components/UsagePanel.tsx +++ b/src/ui/components/UsagePanel.tsx @@ -1,4 +1,6 @@ import type { OpenCodeUsage, UsageWindow } from "../types"; +import { attributedUsageGroups, attributionCoverageWarning } from "../usage-view.js"; +import { billingLabel, evidenceSourceLabel } from "../model-control.js"; import { Button, Icon, Panel } from "./Primitives"; const windows: Array<{ label: string; value: UsageWindow }> = [ @@ -8,11 +10,8 @@ const windows: Array<{ label: string; value: UsageWindow }> = [ { label: "All time", value: "all" }, ]; -function formatCount(value: number) { - return new Intl.NumberFormat(undefined, { notation: value >= 100_000 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value); -} - -function formatCurrency(value: number) { +function formatCurrency(value: number | null | undefined) { + if (value === null || value === undefined) return "Not reported"; const maximumFractionDigits = value > 0 && value < 0.01 ? 4 : 2; return new Intl.NumberFormat(undefined, { style: "currency", @@ -22,6 +21,11 @@ function formatCurrency(value: number) { }).format(value); } +function formatCount(value: number | null | undefined) { + if (value === null || value === undefined) return "Not reported"; + return new Intl.NumberFormat(undefined, { notation: value >= 100_000 ? "compact" : "standard", maximumFractionDigits: 1 }).format(value); +} + function formatGeneratedAt(value?: string) { if (!value) return "Not loaded"; const date = new Date(value); @@ -61,7 +65,7 @@ export function UsagePanel({

Local OpenCode accounting

Usage

-

Aggregate token and recorded-cost history from OpenCode. Prompts and credentials are never read.

+

Tokens are usage. OpenCode-recorded cost is not a provider bill or subscription charge. Prompts and credentials are never read.