Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,12 @@ updates:
- vitest
- "@vitest/*"
ignore:
# pi-ai follows the line the main dsh installs, not the registry. The bridge
# loads pi from the wrapper's copy, so our pin has to satisfy the range the
# pi-ai follows the line the main dsh installs, not the registry. The plane
# adapter loads pi from the wrapper's copy, so our pin has to satisfy the range the
# wrapper declares - now `^1.1.0`, where minor releases stay inside the range
# and a major does not. A second pi beside the wrapper's is the failure this
# prevents: two module instances drift apart the moment either side moves, and
# `test/google-bridge.test.ts` asserts the pin satisfies the range, so a major
# `test/host-versions.test.ts` asserts the pin satisfies the range, so a major
# here would land red rather than break something quietly. Minors are
# welcome - they stay inside the wrapper's range - so only majors are ignored.
- dependency-name: "@earendil-works/pi-ai"
Expand Down
25 changes: 15 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -374,17 +374,22 @@ jobs:
# package.json is not (every dependency bump touches it), so a
# change to its `test:e2e:host` script alone is exercised only by
# the nightly run.
# Until P-R1 moves the host seam under src/host/, the modules that
# install the host patches and mounts whose behaviour depends on the
# real loader and apply order (the hidden-route mount, the listing
# patch and adapter decoration, the Google bridge, the fetch patch,
# the plane bases and the stream hook) are in scope by name. Every
# other row producer (key-capture.ts, models-catalog.ts, config.ts,
# the generated catalog) is covered on each pull request by the
# in-process characterization of the same S0_TODAY rows, and here by
# the nightly run.
# The host seam lives under src/host/ (the route claim, the plane
# adapter, the Settings graft, the host-module loaders), and the
# modules whose behaviour depends on the real loader and apply order
# sit beside it by name: the composition (lifecycle.ts, identity.ts),
# the host-module seam (cordis-context.ts), the remaining discovery
# patch (models-discovery.ts), and the two modules that still install
# host-level patches until P6/P10a move them under src/host/: the
# global fetch wrapper (fetch-patch.ts, whose order the probe reads
# as fetchInnermost) and the llm/stream hook (stream-hook.ts). Every
# other row producer (the pure
# plane profile and model in src/utils/, key-capture.ts,
# models-catalog.ts, config.ts, the generated catalog) is covered on
# each pull request by the in-process characterization of the same
# S0_TODAY rows, and here by the nightly run.
if printf '%s\n' "$changed" \
| grep -Eq '^(src/host/|src/lifecycle\.ts$|src/identity\.ts$|src/responses-provider\.ts$|src/responses-routes\.ts$|src/models-discovery\.ts$|src/google-bridge\.ts$|src/fetch-patch\.ts$|src/pi-provider\.ts$|src/stream-hook\.ts$|cordis\.patch\.yml$|test/e2e/host/|test/e2e/host-binary\.e2e\.ts$|test/support/host\.ts$|test/support/host-versions\.ts$|patches/|\.github/workflows/ci\.yml$|vitest\.e2e\.config\.ts$|vite\.config\.ts$)'; then
| grep -Eq '^(src/host/|src/lifecycle\.ts$|src/identity\.ts$|src/cordis-context\.ts$|src/models-discovery\.ts$|src/fetch-patch\.ts$|src/stream-hook\.ts$|cordis\.patch\.yml$|test/e2e/host/|test/e2e/host-binary\.e2e\.ts$|test/support/host\.ts$|test/support/host-versions\.ts$|patches/|\.github/workflows/ci\.yml$|vitest\.e2e\.config\.ts$|vite\.config\.ts$)'; then
echo "run=true" >> "$GITHUB_OUTPUT"
else
echo "run=false" >> "$GITHUB_OUTPUT"
Expand Down
31 changes: 18 additions & 13 deletions AGENTS.md

Large diffs are not rendered by default.

28 changes: 14 additions & 14 deletions CONTRIBUTING.md

Large diffs are not rendered by default.

132 changes: 63 additions & 69 deletions README.md

Large diffs are not rendered by default.

132 changes: 64 additions & 68 deletions README.zh-CN.md

Large diffs are not rendered by default.

16 changes: 3 additions & 13 deletions cordis.patch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,9 @@
# Config-only overrides — the card deliberately hides these, because each is a
# literal, a marker, or a reference whose default fits every documented setup.
# See src/settings-fields.ts CONFIG_ONLY_FIELDS.
# providers: [string] provider route keys to intercept (default: opencode, opencode-go,
# opencode-responses, opencode-anthropic, opencode-google,
# opencode-go-responses, opencode-go-anthropic, opencode-go-google — covers every
# route this layer declares, including the Google bridge's).
# providers: [string] route ids whose turns this plugin decorates (default: opencode,
# opencode-go; both planes are always included, so only a relay route
# needs adding).
# userAgent: string optional custom User-Agent override (default empty = use OpenCode CLI UA).
# originClient: string x-opencode-client value (default 'cli').
# sessionIdEnv: string env var carrying a caller-supplied session id (default 'OPENCODE_SESSION_ID').
Expand All @@ -44,9 +43,6 @@
# families: ['opencode.ai/zen', 'opencode.ai/inference']).
# usageBaseURL: string OpenCode Go gateway base URL (default 'https://opencode.ai/zen/go/v1').
# credentialOrg: string org id (wrk_...) sent with an account credential that came from elsewhere.
# maxContextWindow: number optional ceiling clamping model context windows (e.g. 131072).
# modelOverrides: object per-model overrides for contextWindow, maxTokens and metadata (default {}).
# modelRoutes: object per-model wire routing: { <id>: { api, baseURL } } (default {}).
# debug: true|false log every streamed call that receives the header.
# debugFile: path optional append target for stream debug JSONL; written only while debug is on.
- insert:
Expand All @@ -56,12 +52,6 @@
providers:
- opencode
- opencode-go
- opencode-responses
- opencode-anthropic
- opencode-google
- opencode-go-responses
- opencode-go-anthropic
- opencode-go-google
injectUserAgent: true
userAgent: ""
injectOriginHeaders: true
Expand Down
25 changes: 17 additions & 8 deletions docs/credentials.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,20 @@ The plugin's default is the deployment's own credential: whatever `apiKeyEnv` th

## Current request wiring

The primary fetch fallback keeps real request auth untouched, then resolves a missing or placeholder credential in this order:
**The plane adapter resolves a plane's key by `llm-pi-ai`'s own rule (D20)**, in `planeApiKeyResolver` (`src/host/plane-provider.ts`), before any request is built:

captured → declared environment key → installation key (opt-in) → nothing
a named apiKeyEnv → the credentials service (the launch environment only when no service is mounted)
no reference → pi's own lookup (a Sign-in record under llm-pi-ai/<plane>, then OPENCODE_API_KEY) → the installation's store (opt-in)

**pi's own lookup reads `OPENCODE_API_KEY` on both planes** (pi 1.1.0's `providers/opencode.js` and `providers/opencode-go.js` both name that one variable). So a Go entry that names no reference authenticates every Go turn with the Zen key, and a Go key stored in DSH Credentials reaches a Go turn only as the entry's `apiKeyEnv: OPENCODE_GO_API_KEY`. Before P-R1 the Go plane's Responses, Messages and Google turns defaulted to `OPENCODE_GO_API_KEY`; that default went with the hidden routes, and the README's "Changed in 1.x" names it. The meter inherits the consequence: the fetch interceptor files the bearer of a Go-plane request as a Go key, and the meter's `auto` order puts that captured key ahead of the configured `OPENCODE_GO_API_KEY`, so after the first Go turn of a no-reference Go entry the meter sends the Zen key to the Go usage endpoint, which refuses it. `test/host-planes.test.ts` pins both turn cases on the Go plane, and the meter's switch from the Go key to the captured Zen key.

A named reference that resolves nothing is `MISSING_CREDENTIAL`, and no request is sent; a credentials service that fails propagates its own error rather than reading as a missing key. A captured or literal key never answers for the adapter. The installation's store is read only when the deployment sets `readOpencodeStore`, only for a plane that names no reference, and only when pi's own lookup would find nothing: the resolver's answer is handed to pi as the request's explicit key, which pi lets win over its own lookup, so a store key returned any earlier would outrank a Sign-in record or an exported `OPENCODE_API_KEY`.

That third step reads the OpenCode installation's own store, and only when the deployment sets `readOpencodeStore`. The Google bridge resolves the route's own reference first and falls back the same way. Two sources that are no longer in this branch — the DSH grant and the login session — are `v2` work.
**The fetch interceptor's fallback is the second, narrower path.** It keeps real request auth untouched and fills only a missing or placeholder credential on gateway traffic, in this order:

The `configured` key-source policy still skips captured fallback; an API key already present in a request is never displaced. Empty and placeholder environment values do not block the login fallback. The Google bridge still raises for an unresolved named reference rather than replacing it with a login.
captured → declared environment key → installation key (opt-in) → nothing

The `configured` key-source policy skips the captured fallback; an API key already present in a request is never displaced. Two sources that are no longer in this branch — the DSH grant and the login session — are `v2` work.

Authentication follows the wire format: Bearer for OpenAI, `x-api-key` for Anthropic `/messages`, and `x-goog-api-key` for Google generation. Header extraction skips placeholders when another header carries a real key; placeholder detection is case-insensitive. The URL's pathname determines the plane, not query text or a conflicting turn provider.

Expand All @@ -32,14 +39,14 @@ These contracts are exercised with fixture grants and SQLite databases through `

One reference resolves per route:

1. **The route's `apiKeyEnv`** — the reference the deployment declared, resolved through the credentials service with the launch environment as fallback. This is the zero-configuration default, and the whole behavior for a deployment that declares nothing else.
1. **The route's `apiKeyEnv`** — the reference the deployment declared, resolved through the credentials service when one is mounted and through the launch environment only when none is (a service miss does not fall through to the environment, as in `llm-pi-ai`). This is the zero-configuration default, and the whole behavior for a deployment that declares nothing else.
2. **A named reference that misses fails loud.** Never hand pi an `undefined` for a named ref: it would pick up an unrelated ambient key (`OPENAI_API_KEY` and friends) and bill another tenant.

Where a route names no reference at all, the chain continues into the login and the installation's store — "The handoff" below is where that order lives.

## The planes are separate, so their credentials are

Zen credentials serve Zen calls, Go credentials serve Go calls, and never the other way: the planes are different accounts with different keys, different endpoints and different catalogs.
Zen credentials serve Zen calls, Go credentials serve Go calls: the planes are different accounts with different keys, different endpoints and different catalogs. On a plane turn that holds when each entry names its own reference; an entry that names none takes pi's `OPENCODE_API_KEY` on either plane (above), which is why the Go entry should name `OPENCODE_GO_API_KEY`.

A route's credential is an **API key** — a reference in the credential store, or the environment — and that is a measured constraint rather than a first choice: the section below shows a login token is refused by the model endpoints the same way a wrong key is.

Expand Down Expand Up @@ -72,7 +79,7 @@ v1 (`console login`) additionally reads `/api/user` and `/api/orgs`, stores the

## What the plugin never touches

- **The `google` route name.** A real deployment may configure Google AI Studio under that id; the plugin's internal route is named `opencode-google` precisely so that name stays free.
- **Any route but its two planes.** Since 1.x the plugin mounts no route of its own: a Google model on either plane is served on `opencode` or `opencode-go`, so a real deployment's `google` route (Google AI Studio, say) is never touched.
- **The CLI's `auth.json`.** Reading it couples the plugin to another program's private file format; writing it collides with the program that owns it. The database that replaced it is read only when `readOpencodeStore` is on, and is never written.

## What the seam enforces about writes (verified, not assumed)
Expand Down Expand Up @@ -141,6 +148,8 @@ Two more details worth copying. Secrets are `Redacted`, so they are never string

So the shape to copy is two-layered, and both layers already exist here in some form: a source list (literal, config, installation, captured request) and an order that puts everything the user declared ahead of everything inferred. Reading a file is one entry in the first list, and the switch that turns it on belongs to the user, not to this plugin's default.

> **(`v2` record, from before 1.x.)** From here to the end of this section, the text describes the Google bridge (`google-bridge.ts`) and `responses-provider.ts`, both deleted in 1.x, and the tests that went with them. Plane credentials are resolved by `planeApiKeyResolver` in `src/host/plane-provider.ts` (D20, above); a login token would join that resolver's no-reference branch, where the opt-in store already sits. The measurements and the two obstacles below are kept as history.

**The two resolution paths, and which one is a gap.** `resolveGatewayKey` is deliberately two-faced, and the difference is tested:

- **A named reference that resolves nothing RAISES.** The message carries the reference and the route, and `MISSING_CREDENTIAL` is in it. The reason is in the test: `undefined` would tell the underlying adapter to "resolve ambient auth instead", and it would bill whatever key it found first.
Expand Down Expand Up @@ -249,7 +258,7 @@ What a login is FOR is measured, not assumed: the account's session token, on `/

Built, and the opt-in reader it prepared for:

- **The installation's own store, read only when asked.** `readOpencodeStore` is a config field, default `false`, with a card control beside the credential-source select. When it is on, `resolveOpencodeStoreKey` reads the credential OpenCode itself holds for the plane in hand and the step sits LAST in all three key-source policies — after a literal, after a captured request, after the named reference and the environment — so a credential declared in DSH always wins. Both paths that resolve a request's key consult it: the fetch interceptor's Authorization fallback, and the Google bridge's per-request resolver. Nothing logs a value from it, and `node:sqlite` keeps the module on the host side of the bundle rule.
- **The installation's own store, read only when asked.** `readOpencodeStore` is a config field, default `false`, with a card control beside the credential-source select. When it is on, `resolveOpencodeStoreKey` reads the credential OpenCode itself holds for the plane in hand and the step sits LAST in all three key-source policies — after a literal, after a captured request, after the named reference and the environment — so a credential declared in DSH always wins. Both paths that resolve a request's key consult it: the fetch interceptor's Authorization fallback, and the plane adapter's resolver (`planeApiKeyResolver`) for a plane that names no reference. Nothing logs a value from it, and `node:sqlite` keeps the module on the host side of the bundle rule.

## Reading the installation's own credential store needs no dependency

Expand Down
Loading
Loading