@legioncodeinc/rflectr 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/dist/cli.js +4 -1
  2. package/dist/cli.js.map +1 -0
  3. package/package.json +4 -1
  4. package/.markdown-link-check.json +0 -7
  5. package/AGENTS.md +0 -169
  6. package/assets/733630021_1421561133353555_3999689754075308337_n.jpg +0 -0
  7. package/assets/github-home-image.png +0 -0
  8. package/assets/og-image.jpg +0 -0
  9. package/assets/og-image.png +0 -0
  10. package/assets/og-image.psd +0 -0
  11. package/assets/rflectr-no-bg.png +0 -0
  12. package/assets/vertex-models.example.json +0 -14
  13. package/library/README.md +0 -39
  14. package/library/issues/README.md +0 -46
  15. package/library/issues/backlog/README.md +0 -26
  16. package/library/issues/completed/README.md +0 -13
  17. package/library/issues/in-work/README.md +0 -13
  18. package/library/knowledge/README.md +0 -34
  19. package/library/knowledge/private/README.md +0 -40
  20. package/library/knowledge/private/ai/README.md +0 -8
  21. package/library/knowledge/private/ai/model-discovery-classification.md +0 -81
  22. package/library/knowledge/private/ai/translation-layer.md +0 -88
  23. package/library/knowledge/private/architecture/README.md +0 -10
  24. package/library/knowledge/private/architecture/launch-flow-claude.md +0 -93
  25. package/library/knowledge/private/architecture/system-overview.md +0 -108
  26. package/library/knowledge/private/auth/README.md +0 -9
  27. package/library/knowledge/private/auth/oauth-device-flows.md +0 -95
  28. package/library/knowledge/private/data/README.md +0 -8
  29. package/library/knowledge/private/data/preferences-config.md +0 -87
  30. package/library/knowledge/private/data/provider-registry.md +0 -126
  31. package/library/knowledge/private/infrastructure/README.md +0 -7
  32. package/library/knowledge/private/infrastructure/server-gateway.md +0 -87
  33. package/library/knowledge/private/integrations/README.md +0 -8
  34. package/library/knowledge/private/integrations/harnesses.md +0 -87
  35. package/library/knowledge/private/integrations/local-proxy.md +0 -82
  36. package/library/knowledge/private/security/README.md +0 -9
  37. package/library/knowledge/private/security/credential-storage.md +0 -129
  38. package/library/knowledge/private/standards/documentation-framework.md +0 -154
  39. package/library/knowledge/public/README.md +0 -49
  40. package/library/knowledge/public/faqs/README.md +0 -7
  41. package/library/knowledge/public/faqs/troubleshooting.md +0 -92
  42. package/library/knowledge/public/guides/README.md +0 -13
  43. package/library/knowledge/public/guides/ai-agents.md +0 -273
  44. package/library/knowledge/public/guides/api-server.md +0 -108
  45. package/library/knowledge/public/guides/claude-desktop.md +0 -382
  46. package/library/knowledge/public/guides/codex.md +0 -296
  47. package/library/knowledge/public/guides/gemini-cli.md +0 -105
  48. package/library/knowledge/public/guides/model-compatibility.md +0 -80
  49. package/library/knowledge/public/guides/providers.md +0 -90
  50. package/library/knowledge/public/overview/README.md +0 -7
  51. package/library/knowledge/public/overview/what-is-rflectr.md +0 -71
  52. package/library/notes/README.md +0 -21
  53. package/library/requirements/README.md +0 -51
  54. package/library/requirements/backlog/README.md +0 -30
  55. package/library/requirements/completed/README.md +0 -14
  56. package/library/requirements/completed/prd-001-cli-core-launch-orchestration/prd-001-cli-core-launch-orchestration-index.md +0 -205
  57. package/library/requirements/completed/prd-001-cli-core-launch-orchestration/qa/.gitkeep +0 -0
  58. package/library/requirements/completed/prd-002-provider-registry/prd-002-provider-registry-index.md +0 -263
  59. package/library/requirements/completed/prd-002-provider-registry/qa/.gitkeep +0 -0
  60. package/library/requirements/completed/prd-003-model-discovery-classification/prd-003-model-discovery-classification-index.md +0 -260
  61. package/library/requirements/completed/prd-003-model-discovery-classification/qa/.gitkeep +0 -0
  62. package/library/requirements/completed/prd-004-translation-layer/prd-004-translation-layer-index.md +0 -196
  63. package/library/requirements/completed/prd-004-translation-layer/qa/.gitkeep +0 -0
  64. package/library/requirements/completed/prd-005-local-proxy-catalog-routing/prd-005-local-proxy-catalog-routing-index.md +0 -176
  65. package/library/requirements/completed/prd-005-local-proxy-catalog-routing/qa/.gitkeep +0 -0
  66. package/library/requirements/completed/prd-006-credential-storage/prd-006-credential-storage-index.md +0 -190
  67. package/library/requirements/completed/prd-006-credential-storage/qa/.gitkeep +0 -0
  68. package/library/requirements/completed/prd-007-oauth-device-flows/prd-007-oauth-device-flows-index.md +0 -208
  69. package/library/requirements/completed/prd-007-oauth-device-flows/qa/.gitkeep +0 -0
  70. package/library/requirements/completed/prd-008-preferences-tiers-favorites/prd-008-preferences-tiers-favorites-index.md +0 -249
  71. package/library/requirements/completed/prd-008-preferences-tiers-favorites/qa/.gitkeep +0 -0
  72. package/library/requirements/completed/prd-009-codex-integration/prd-009-codex-integration-index.md +0 -212
  73. package/library/requirements/completed/prd-009-codex-integration/qa/.gitkeep +0 -0
  74. package/library/requirements/completed/prd-010-gemini-cli-integration/prd-010-gemini-cli-integration-index.md +0 -211
  75. package/library/requirements/completed/prd-010-gemini-cli-integration/qa/.gitkeep +0 -0
  76. package/library/requirements/completed/prd-011-claude-desktop-integration/prd-011-claude-desktop-integration-index.md +0 -228
  77. package/library/requirements/completed/prd-011-claude-desktop-integration/qa/.gitkeep +0 -0
  78. package/library/requirements/completed/prd-012-server-gateway/prd-012-server-gateway-index.md +0 -356
  79. package/library/requirements/completed/prd-012-server-gateway/qa/.gitkeep +0 -0
  80. package/library/requirements/in-work/README.md +0 -19
  81. package/library/requirements/reports/README.md +0 -31
  82. package/scripts/refresh-models-dev-cache.mjs +0 -34
  83. package/test-proxy.ts +0 -19
  84. package/test-split.js +0 -1
@@ -1,176 +0,0 @@
1
- # PRD-005: Local Proxy & Catalog Routing *(Retroactive)*
2
-
3
- > **Status:** Shipped
4
- > **Priority:** —
5
- > **Effort:** —
6
- > **Written:** June 2026
7
- > **Retroactive:** Yes — written after implementation (rflectr v0.2.7).
8
- > **Source:** `src/proxy.ts`, `src/catalog.ts`, `src/proxy-shared.ts`, `src/upstream-forward.ts`
9
-
10
- ---
11
-
12
- ## Overview
13
-
14
- rflectr re-points a host tool (Claude Code) at an alternative model backend by pointing `ANTHROPIC_BASE_URL` at a throwaway HTTP server it spins up on `127.0.0.1:<random ephemeral port>`. This **local proxy** accepts requests in Anthropic's wire format (`POST /v1/messages`, `GET /v1/models`) and, per request, either forwards them raw to a provider that already speaks Anthropic, or hands them to the Vercel AI SDK adapter (PRD-004) for any other provider.
15
-
16
- The proxy is created at launch and torn down when the host process exits. It exists in two shapes: a **single-model** wrapper (`startProxy`) for an ordinary launch, and a **multi-route catalog** (`startProxyCatalog`) for switch-menu sessions where Claude Code's `/model` picker can hop between a starting model and the user's favorites. Catalog routing depends on a small set of pure route-builder functions in `src/catalog.ts` and an alias scheme (`aliasModelId`) that rewrites non-`claude-*` model ids into a form Claude Code's gateway model discovery will accept.
17
-
18
- This PRD documents the proxy server, its request-dispatch model, the `ProxyRoute` carrier type, the synthetic model catalog, the alias scheme, the catalog route builders, and the shared upstream-forwarding helpers.
19
-
20
- ## What Was Built
21
-
22
- - A local HTTP server bound to `127.0.0.1` on an OS-chosen ephemeral port (`server.listen(0, '127.0.0.1', …)`, `src/proxy.ts:294`) that serves `HEAD /`, `GET /v1/models`, `GET /v1/models/:id`, and `POST /v1/messages`.
23
- - A per-request dispatch that resolves a `ProxyRoute` by model id and branches on `route.modelFormat`: `anthropic` → raw passthrough; otherwise → SDK adapter (`src/proxy.ts:211`, `src/proxy.ts:230`).
24
- - Two entry points: `startProxyCatalog(routes, defaultAliasId, debug)` (`src/proxy.ts:112`) and the single-model wrapper `startProxy(completionsUrl, modelId, debug, contextWindow?, sdk?, apiKey?)` (`src/proxy.ts:315`), which builds a one-route catalog.
25
- - A `ProxyHandle` (`{ port, token, close() }`) whose `token` (a `randomUUID()`) becomes the child's `ANTHROPIC_API_KEY`, so only the launched child can call the proxy (`src/proxy.ts:117`, `src/proxy.ts:177`).
26
- - The `aliasModelId(realId, providerId)` rewrite that makes non-`claude-*` ids gateway-discovery-safe as `anthropic-{provider}__{id}` (`src/proxy.ts:96`).
27
- - A synthetic `GET /v1/models` catalog, one entry per route, each carrying `context_window` via `formatAnthropicModelEntry` / `formatAnthropicModelList` (`src/proxy.ts:138`, `src/proxy.ts:162`; `src/server/models.ts:58`).
28
- - Catalog route builders in `src/catalog.ts`: `localModelToRoute`, `zenGoModelToRoute`, `makeRouteResolver`, and `buildCatalogRoutes` (capped at `MAX_MODEL_CATALOG = 20`, `src/constants.ts:51`).
29
- - Shared upstream forwarding in `src/upstream-forward.ts` (`relayAnthropicMessages`, `postJsonUpstream`, `anthropicUpstreamHeaders`, `UpstreamUnreachableError`), reused by both this proxy and the `server` command's router (PRD-012).
30
- - Format-agnostic glue in `src/proxy-shared.ts` (`sseChunk`, `encodeToolUseId`/`splitToolUseId`, `grabRoundTripSignature`, `silenceSdkWarnings`, …) and request/response shapes in `src/proxy-types.ts`.
31
-
32
- ## Goals
33
-
34
- - Let the host tool talk to any registered backend without modifying `settings.json` — env-var-only, child-process-scoped (see PRD-001).
35
- - Translate only when necessary: a provider that already speaks Anthropic gets a raw byte-for-byte relay; everything else goes through the single SDK translation path (PRD-004).
36
- - Support mid-session model switching by advertising a multi-model catalog the host can pick from, while keeping each model's real upstream id and key hidden behind a stable alias.
37
- - Report accurate context windows to the host's status bar in single-model launches.
38
- - Lock the proxy to the launched child via a per-session token.
39
-
40
- ## Non-Goals
41
-
42
- - **Translation internals.** Wire-format mapping, endpoint selection, and provider quirks belong to the SDK adapter and provider factory (PRD-004). The proxy only chooses *which* path to dispatch to.
43
- - **The Codex (`/v1/responses`) and Gemini (`/v1beta/...`) proxies.** Those are sibling servers that share `proxy-shared.ts` but expose different endpoints — see PRD-009 and PRD-010.
44
- - **The standalone `server` gateway.** That long-lived multi-provider gateway reuses `upstream-forward.ts` but is its own surface (PRD-012).
45
- - **Favorites collection / persistence.** How favorites are chosen and stored is PRD-008; this PRD consumes a `FavoriteModel[]` to build routes.
46
- - **Live-switch context-window accuracy.** In gateway-discovery (switch-menu) mode the host fetches `/v1/models` once at startup; the displayed window reflects the launch model only (see Risks).
47
-
48
- ## Features
49
-
50
- | Feature | Description | Source |
51
- | --- | --- | --- |
52
- | Ephemeral local server | Binds `127.0.0.1:0`; OS picks the port; returned in `ProxyHandle.port`. | `src/proxy.ts:294` |
53
- | Per-session token auth | `POST /v1/messages` requires `x-api-key`/`Bearer` == the proxy's `randomUUID()` token, else 401. | `src/proxy.ts:117`, `src/proxy.ts:177` |
54
- | Health-check ping | `HEAD /` → 200 (Claude Code startup ping). | `src/proxy.ts:148` |
55
- | Synthetic model list | `GET /v1/models` returns one entry per route with `context_window`; `GET /v1/models/:id` returns a single entry or 404. | `src/proxy.ts:155` |
56
- | Anthropic passthrough | `modelFormat === 'anthropic'` → `relayAnthropicMessages` to `{baseUrl}/v1/messages`, forwarding `anthropic-beta`. | `src/proxy.ts:211` |
57
- | SDK-backed dispatch | `isSdkMigratedNpm(route.npm)` → `createLanguageModel` + `streamAnthropicResponse`/`generateAnthropicResponse`. | `src/proxy.ts:230` |
58
- | Streaming + non-streaming | Honors `body.stream`; streams Anthropic SSE or returns JSON. | `src/proxy.ts:255` |
59
- | `aliasModelId` | Rewrites non-`claude-*` ids to `anthropic-{providerId}__{id}` for gateway discovery; `claude-*` pass through. | `src/proxy.ts:96` |
60
- | Alias-tolerant route lookup | `routeLookupIds` resolves prefix/suffix/`models/`-prefixed variants to the same route. | `src/proxy.ts:103` |
61
- | Single-model wrapper | `startProxy` builds a one-route catalog from a completions URL + optional `sdk` carrier. | `src/proxy.ts:315` |
62
- | Catalog route builders | `localModelToRoute`, `zenGoModelToRoute`, `makeRouteResolver`, `buildCatalogRoutes` (cap 20, dedup vs. starting route). | `src/catalog.ts:11`–`100` |
63
- | Shared upstream forwarding | `relayAnthropicMessages`, `postJsonUpstream`, `anthropicUpstreamHeaders`, `UpstreamUnreachableError`. | `src/upstream-forward.ts` |
64
- | Trace logging | When `debug`, redacted secure log via `appendSecureLog` (0600). | `src/proxy.ts:24`, `src/proxy.ts:40` |
65
-
66
- ## Architecture & Implementation
67
-
68
- ### Request dispatch flow
69
-
70
- ```mermaid
71
- flowchart TD
72
- req["POST /v1/messages (Anthropic format)"]
73
- req --> auth{"x-api-key == proxy token?"}
74
- auth -->|no| e401["401 Invalid proxy token"]
75
- auth -->|yes| lookup["lookupRoute(byAlias, body.model)\nfall back to defaultRoute"]
76
- lookup --> fmt{"route.modelFormat"}
77
- fmt -->|anthropic| fwd["relayAnthropicMessages()\n→ {upstreamUrl}/v1/messages (raw)"]
78
- fmt -->|"openai (else)"| sdkguard{"isSdkMigratedNpm(route.npm)"}
79
- sdkguard -->|true| adapter["createLanguageModel +\nstream/generateAnthropicResponse"]
80
- sdkguard -->|false| e500["500 No SDK provider configured"]
81
- fwd --> resp["Anthropic SSE / JSON to host"]
82
- adapter --> resp
83
- ```
84
-
85
- - **Token gate.** `extractApiKey(req)` (from `x-api-key` or `Bearer`) must equal `proxyToken`, else `401 Invalid proxy token` (`src/proxy.ts:176`). The token is a `randomUUID()` generated per proxy (`src/proxy.ts:117`) and handed to the child as its `ANTHROPIC_API_KEY`.
86
- - **Route resolution.** `const route = lookupRoute(byAlias, originalModel) ?? defaultRoute` (`src/proxy.ts:195`). `lookupRoute` tries each id produced by `routeLookupIds` (`src/proxy.ts:103`), which strips the `[1m]` context suffix and handles a leading `models/` prefix so Claude Code's id variants resolve to one route.
87
- - **Anthropic passthrough.** The raw Anthropic body (with `model` swapped to `route.realModelId`) is relayed to `${upstreamUrl}/v1/messages`, forwarding the inbound `anthropic-beta` header (`src/proxy.ts:211`–`224`). Failures surface as `502` and, for network errors, `UpstreamUnreachableError` (`src/upstream-forward.ts:45`).
88
- - **SDK path.** `sdkTranslateRequest(body, route.npm, …)` builds SDK params, `createLanguageModel({ npm, modelId, apiKey, baseURL, … })` resolves the provider, then `streamAnthropicResponse`/`generateAnthropicResponse` map to Anthropic output (`src/proxy.ts:230`–`281`). A non-`anthropic` route with no SDK-migrated npm is a misconfiguration → `500` (`src/proxy.ts:284`).
89
- - **Body decoding.** `readBody` honors `Content-Encoding` (gzip/deflate/br/zstd) and caps the body at 50 MB (`src/http-utils.ts:34`).
90
-
91
- ### The `ProxyRoute` carrier
92
-
93
- Each route is self-contained — it carries everything needed to serve a request (`src/proxy.ts:72`): `aliasId` (advertised id), `realModelId` (sent upstream), `displayName`, `upstreamUrl`, `apiKey` (per-route; empty → 401), `modelFormat`, `contextWindow`, and the SDK/provider fields `npm`, `baseURL`, `providerId`, `authType`, `oauthAccountId`, `supportedParameters`, `reasoning`, `interleavedReasoningField`. `upstreamUrl` is a full chat-completions URL for openai-format routes, or a base URL **without** `/v1` for anthropic routes (the relay appends `/v1/messages`).
94
-
95
- ### Route resolution & catalog assembly (`src/catalog.ts`)
96
-
97
- - `localModelToRoute(lp, model)` (`src/catalog.ts:11`) maps a discovered local-provider model to a `ProxyRoute`, returning `null` for unserveable models (anthropic without `baseUrl`; openai without an SDK npm and without a `completionsUrl`).
98
- - `zenGoModelToRoute(model, apiKey)` (`src/catalog.ts:33`) maps a Zen/Go cloud model; `unsupported` formats return `null`. openai-format Zen/Go models route through `@ai-sdk/openai-compatible` with `baseURL = ${backend.baseUrl}/v1`; anthropic-format stay direct passthrough (no `npm`).
99
- - `makeRouteResolver(localProviders, zenModels, goModels, zenGoApiKey)` (`src/catalog.ts:53`) returns a `(providerId, modelId) => ProxyRoute | undefined` closure that dispatches `zen`/`go` to the cloud builder and anything else to the local builder.
100
- - `buildCatalogRoutes(startingRoute, favorites, resolveRoute, max = 20)` (`src/catalog.ts:81`) resolves each favorite, dedupes against the starting route's `aliasId`, caps at `MAX_MODEL_CATALOG`, and reports `droppedFavorites` (stale/unresolvable). The starting route is always first.
101
-
102
- Both builders run the model id and `contextWindow` through `claudeCodeClientModelId(aliasModelId(id, providerId), window)` so the advertised alias is gateway-safe and carries the `[1m]` suffix when the window exceeds the default.
103
-
104
- ### Alias scheme
105
-
106
- `aliasModelId(realId, providerId)` (`src/proxy.ts:96`) leaves `claude-*` ids unchanged and rewrites everything else to `anthropic-{slug}__{realId}`, where `slug` is the provider id lowercased and non-alphanumerics collapsed to `-`. Using the stable provider **id** (not display name) means renaming a provider does not break the alias. Claude Code's gateway model discovery only surfaces ids beginning `claude` or `anthropic`; this rewrite is what makes a third-party model selectable in the `/model` picker. (A side effect: after a switch-menu session a bare `claude` may still show a relay alias, because Claude Code caches the gateway id.)
107
-
108
- ### Single-model vs. catalog launch
109
-
110
- `startProxy` (`src/proxy.ts:315`) is a thin wrapper that constructs one `ProxyRoute` from a completions URL plus an optional `sdk` carrier (`{ npm, baseURL, upstreamModelId, providerId, authType, … }`) and an `apiKey`, then calls `startProxyCatalog` with that single route as the default. Switch-menu launches instead call `buildCatalogRoutes` first and pass the full route array to `startProxyCatalog` (consumed by PRD-001 / PRD-008).
111
-
112
- ## API Surface
113
-
114
- The proxy listens on `http://127.0.0.1:<ProxyHandle.port>`.
115
-
116
- ### `HEAD /`
117
- Health-check ping → `200`, empty body (`src/proxy.ts:148`).
118
-
119
- ### `GET /v1/models`
120
- Returns the synthetic catalog: `{ data: [...], has_more: false, first_id, last_id }`, one entry per route, each with `context_window` resolved by `resolveContextWindow` (`src/proxy.ts:138`, `src/server/models.ts:89`). No auth required.
121
-
122
- ### `GET /v1/models/:id`
123
- Returns a single formatted entry for the resolved route, or `404 not_found_error` if the id matches no route (`src/proxy.ts:156`–`166`).
124
-
125
- ### `POST /v1/messages`
126
- The main translation path. Requires the proxy token (`401` otherwise). Body is Anthropic `messages` format. Response is Anthropic SSE when `body.stream` is truthy, else Anthropic JSON. Error envelope is always `{ type: 'error', error: { type, message } }` — `400` invalid JSON, `401` bad/missing key, `500` misconfigured route, `502` upstream failure (`src/proxy.ts:175`–`285`).
127
-
128
- Any other method/path → `404 Unknown endpoint` (`src/proxy.ts:289`).
129
-
130
- ## Acceptance Criteria
131
-
132
- - [x] Proxy binds `127.0.0.1` on an OS-chosen ephemeral port and returns it in `ProxyHandle.port` (`src/proxy.ts:294`).
133
- - [x] `POST /v1/messages` rejects requests whose key ≠ the per-session token with `401` (`src/proxy.ts:176`).
134
- - [x] `HEAD /` returns `200` for the host's startup health check (`src/proxy.ts:148`).
135
- - [x] `GET /v1/models` returns one entry per route with a `context_window` field (`src/proxy.ts:138`, `src/server/models.ts:58`).
136
- - [x] `GET /v1/models/:id` returns the matching entry or `404` (`src/proxy.ts:156`).
137
- - [x] `modelFormat === 'anthropic'` routes relay raw to `{upstreamUrl}/v1/messages`, forwarding `anthropic-beta` (`src/proxy.ts:211`).
138
- - [x] Non-anthropic routes with an SDK-migrated `npm` dispatch through `createLanguageModel` + the SDK adapter (`src/proxy.ts:230`).
139
- - [x] A non-anthropic route without a registered SDK npm returns `500` (`src/proxy.ts:284`).
140
- - [x] Streaming is honored via `body.stream`; SSE for streaming, JSON otherwise (`src/proxy.ts:255`).
141
- - [x] `aliasModelId` leaves `claude-*` unchanged and rewrites others to `anthropic-{providerId}__{id}` (`src/proxy.ts:96`).
142
- - [x] `startProxy` is a single-route wrapper around `startProxyCatalog` (`src/proxy.ts:315`).
143
- - [x] `buildCatalogRoutes` dedupes against the starting route, caps at `MAX_MODEL_CATALOG` (20), and reports dropped favorites (`src/catalog.ts:81`).
144
- - [x] `localModelToRoute` / `zenGoModelToRoute` return `null` for unserveable / `unsupported` models (`src/catalog.ts:12`, `src/catalog.ts:34`).
145
- - [x] `relayAnthropicMessages` distinguishes a network failure (`UpstreamUnreachableError`) from an upstream error response (`src/upstream-forward.ts:45`, `src/upstream-forward.ts:72`).
146
- - [x] Upstream forwarding is shared with the `server` router via `src/upstream-forward.ts` (PRD-012).
147
-
148
- ## Files
149
-
150
- | File | Role |
151
- | --- | --- |
152
- | `src/proxy.ts` | The Anthropic-facing proxy: `startProxyCatalog`, `startProxy`, `aliasModelId`, `ProxyRoute`, `ProxyHandle`, request dispatch, synthetic `/v1/models`, token auth, trace logging. |
153
- | `src/catalog.ts` | Route builders: `localModelToRoute`, `zenGoModelToRoute`, `makeRouteResolver`, `buildCatalogRoutes`. |
154
- | `src/proxy-shared.ts` | Format-agnostic glue shared across the Anthropic/Responses/Gemini proxies (SSE, tool-use id round-trip, signature grab, SDK-warning silencer). |
155
- | `src/proxy-types.ts` | Anthropic/Gemini/OpenAI request & response shapes. |
156
- | `src/upstream-forward.ts` | Shared upstream forwarding: `relayAnthropicMessages`, `postJsonUpstream`, Anthropic header helpers, `UpstreamUnreachableError`. |
157
- | `src/http-utils.ts` | `readBody` (Content-Encoding decode + 50 MB cap), `extractApiKey`, `sendJson`. |
158
- | `src/server/models.ts` | `formatAnthropicModelEntry` / `formatAnthropicModelList` / `resolveContextWindow` (consumed for the synthetic catalog). |
159
- | `src/context-model-id.ts` | `claudeCodeClientModelId`, `routeLookupIds`, `stripOneMContextSuffix` (alias id derivation + lookup tolerance). |
160
- | `src/constants.ts` | `MAX_MODEL_CATALOG = 20`. |
161
-
162
- ## Risks & Known Limitations
163
-
164
- - **Switch-menu context window reflects the launch model only.** In gateway-discovery mode Claude Code fetches `/v1/models` once at startup and its discovery payload carries no `context_window`; only `CLAUDE_CODE_MAX_CONTEXT_TOKENS` (fixed at launch) drives the status bar. Single-model launches show the correct window. (Documented in CLAUDE.md / the knowledge doc.)
165
- - **Cached gateway alias.** After a switch-menu session, a bare `claude` may show a relay alias (e.g. `anthropic-opencode-go__deepseek-v4-flash`) because Claude Code caches the gateway id at `~/.claude/cache/gateway-models.json`. Reset with `claude --model sonnet`.
166
- - **Catalog cap.** Catalogs are capped at 20 routes (`MAX_MODEL_CATALOG`); favorites beyond the cap, or unresolvable ones, are silently dropped (surfaced as `droppedFavorites`).
167
- - **Empty per-route key → 401.** A route with an empty `apiKey` returns `401 Missing API key` (`src/proxy.ts:203`); OAuth-only / placeholder-key gaps surface here.
168
- - **`thought_signature` separator collision.** Tool-use ids encode a thought signature as `{id}__ts__{base64url}`; an id literally containing the separator would break round-tripping (extremely unlikely; legacy `::ts::` form still parsed for in-flight sessions). See PRD-004.
169
-
170
- ## Related
171
-
172
- - [`../../../knowledge/private/integrations/local-proxy.md`](../../../knowledge/private/integrations/local-proxy.md) — knowledge doc this PRD is grounded in.
173
- - [`../prd-004-translation-layer/prd-004-translation-layer-index.md`](../prd-004-translation-layer/prd-004-translation-layer-index.md) — the SDK adapter the proxy dispatches non-anthropic routes to.
174
- - [`../prd-001-cli-core-launch-orchestration/prd-001-cli-core-launch-orchestration-index.md`](../prd-001-cli-core-launch-orchestration/prd-001-cli-core-launch-orchestration-index.md) — launch flow that starts and tears down the proxy.
175
- - [`../prd-008-preferences-tiers-favorites/prd-008-preferences-tiers-favorites-index.md`](../prd-008-preferences-tiers-favorites/prd-008-preferences-tiers-favorites-index.md) — favorites that feed `buildCatalogRoutes`.
176
- - [`../prd-012-server-gateway/prd-012-server-gateway-index.md`](../prd-012-server-gateway/prd-012-server-gateway-index.md) — the standalone gateway that reuses `upstream-forward.ts`.
@@ -1,190 +0,0 @@
1
- # PRD-006: Credential Storage & API Key Management *(Retroactive)*
2
-
3
- > **Status:** Shipped
4
- > **Priority:** —
5
- > **Effort:** —
6
- > **Written:** June 2026
7
- > **Retroactive:** Yes — written after implementation (rflectr v0.2.7).
8
- > **Source:** `src/key-setup.ts`, `src/registry/auth-broker.ts`, `src/registry/provider-auth.ts`
9
-
10
- ---
11
-
12
- ## Overview
13
-
14
- `rflectr` re-points Claude Code / Codex / Gemini at alternative model backends, which means it must hold API keys and OAuth tokens for the OpenCode Zen/Go cloud backend and for every registry provider. This PRD documents the credential subsystem: where secrets live, how they are resolved at launch, the interactive key-collection flow, and the per-platform save options.
15
-
16
- The design principle is **secrets never touch the config files**. `providers.json` and `config.json` hold only an `authRef` pointer; the actual secret lives in an environment variable, the OS keyring (via `@napi-rs/keyring`), or — only when the user opts in — a plaintext shell profile / persistent env var. The OS keyring is the default everywhere it is available, and a missing native keyring binary degrades gracefully rather than crashing because the module is loaded through a dynamic `import()` (`src/env.ts:141`, `src/env.ts:155`).
17
-
18
- This is the security-critical surface of the project. The canonical narrative lives in the knowledge base at [`../../../knowledge/private/security/credential-storage.md`](../../../knowledge/private/security/credential-storage.md).
19
-
20
- ---
21
-
22
- ## What Was Built
23
-
24
- - A cross-platform OS credential store backed by `@napi-rs/keyring` (service `rflectr`), with `getPassword` / `setPassword` / `deletePassword` wrappers that never throw — failures are classified into human-readable reasons by `classifyKeyringError()` (`src/env.ts:73`).
25
- - A silent startup read: `resolveOrCollectApiKey()` first calls `resolveApiKey()` and then `readFromCredentialStore()` so that when a key already exists no prompt is shown (`src/key-setup.ts:38`, `src/key-setup.ts:58`).
26
- - An interactive key-collection prompt for the OpenCode Zen/Go key with **platform-specific save options** (macOS / Windows / Linux desktop / Linux headless), implemented in `resolveOrCollectApiKey()` (`src/key-setup.ts:86`–`src/key-setup.ts:185`).
27
- - Immediate session activation: `process.env['OPENCODE_API_KEY']` is set the moment a key is resolved or collected, regardless of the persistence choice (`src/key-setup.ts:62`, `src/key-setup.ts:187`).
28
- - A layered resolution order for *provider* keys via `resolveProviderCredential(providerId, authRef)`: namespaced env var → `env:`-ref → `global:opencode` chain → per-provider keyring account (`src/env.ts:241`).
29
- - A keyring migration protocol (read → write → verify → delete) that lifts legacy `rflectr` / `opencode-starter` entries into the canonical `global:opencode` account only after the new entry verifies (`src/env.ts:199`).
30
- - An OAuth auth broker that delegates login to the OpenCode CLI and copies the resulting tokens into the rflectr keychain (`src/registry/auth-broker.ts:17`), plus a native-vs-broker selector (`src/registry/provider-auth.ts:151`).
31
- - Key validation before import: `validateImportKey()` rejects placeholder/empty/invalid keys by actually probing the provider's model endpoint (`src/registry/validate-import-key.ts:30`).
32
-
33
- ---
34
-
35
- ## Goals
36
-
37
- - Keep secrets out of plaintext config files; store only an `authRef` pointer in the registry.
38
- - Default to the OS-native secure store on every platform, and degrade gracefully when it is unavailable.
39
- - Never block startup on a missing native module — keyring failures are caught and surfaced as diagnostics, not crashes.
40
- - Resolve a usable key at launch with a deterministic, documented priority order.
41
- - Make the key active for the *current* session immediately, independent of where (or whether) it is persisted.
42
- - Validate provider keys before importing them so the registry never holds a known-bad credential.
43
-
44
- ## Non-Goals
45
-
46
- - A custom encryption-at-rest scheme — the OS keyring is the trust anchor.
47
- - Server-mode network authentication — the `server` command's password gate is owned by PRD-012 (`src/server/auth.ts`).
48
- - OAuth device-flow mechanics themselves — token acquisition is PRD-007; this PRD covers only how the resulting tokens are *stored and resolved*.
49
- - Rotating or expiring API keys on a schedule.
50
-
51
- ---
52
-
53
- ## Features
54
-
55
- | # | Feature | Source |
56
- |---|---------|--------|
57
- | F1 | `@napi-rs/keyring` OS credential store via dynamic `import()` (graceful degrade) | `src/env.ts:139`–`src/env.ts:173` |
58
- | F2 | Silent startup read — no prompt when a key already exists | `src/key-setup.ts:38`, `src/key-setup.ts:58` |
59
- | F3 | Per-platform save options (macOS / Windows / Linux desktop / headless) | `src/key-setup.ts:86`–`src/key-setup.ts:185` |
60
- | F4 | `OPENCODE_API_KEY` set in `process.env` immediately, regardless of save choice | `src/key-setup.ts:62`, `src/key-setup.ts:187` |
61
- | F5 | Layered provider-key resolution (`resolveProviderCredential`) | `src/env.ts:241` |
62
- | F6 | `global:opencode` fallback chain (env → keyring → legacy services) | `src/env.ts:176` |
63
- | F7 | Legacy-entry migration (read → write → verify → delete) | `src/env.ts:199` |
64
- | F8 | Keyring error classification (never throws) | `src/env.ts:73` |
65
- | F9 | Secret Service availability probe (Linux) | `src/env.ts:367`, `src/key-setup.ts:79` |
66
- | F10 | OAuth broker → keychain copy | `src/registry/auth-broker.ts:17`, `src/registry/provider-auth.ts:160` |
67
- | F11 | Pre-import key validation (placeholder / invalid / manual-auth) | `src/registry/validate-import-key.ts:30` |
68
- | F12 | `--dry-run` simulates save without writing | `src/key-setup.ts:123`–`src/key-setup.ts:133` |
69
-
70
- ---
71
-
72
- ## Architecture & Implementation
73
-
74
- ### Where secrets live
75
-
76
- Secrets are never written to `providers.json` or `config.json` — those hold only an `authRef` pointer. The actual secret lives in one of three places (`src/env.ts:241`):
77
-
78
- 1. **Env var** — the namespaced `RFLECTR_KEY_<PROVIDER_ID_UPPER>` (highest priority, `rflectrKeyEnvVar()` at `src/env.ts:129`), or whatever `env:VAR_NAME` the `authRef` names (`src/env.ts:252`).
79
- 2. **OS keyring** — service `rflectr` via `@napi-rs/keyring`. Accounts: `provider:<id>` (`src/env.ts:96`), `oauth:provider:<id>` (`src/env.ts:100`, a JSON `StoredOAuthCredential`), and `global:opencode` (`src/env.ts:94`).
80
- 3. **Legacy keyring entries** — `rflectr` / `opencode-starter` accounts, auto-migrated on first successful read (`src/env.ts:199`).
81
-
82
- ### Key resolution order
83
-
84
- For a **provider** key, `resolveProviderCredential(providerId, authRef, diag?)` resolves in this order (`src/env.ts:241`):
85
-
86
- 1. Namespaced env var `RFLECTR_KEY_<ID>` (`src/env.ts:246`).
87
- 2. If `authRef` is an `env:` ref → read that env var (`src/env.ts:252`).
88
- 3. If the keyring account is `global:opencode` → run the `readGlobalOpencodeCredential()` chain (`src/env.ts:256`).
89
- 4. Otherwise → read the per-provider keyring account, decoding/refreshing OAuth JSON if present (`src/env.ts:260`, `src/env.ts:324`).
90
-
91
- For the shared **OpenCode Zen/Go** key, `readGlobalOpencodeCredential()` tries, in order (`src/env.ts:176`): `OPENCODE_API_KEY` env (`resolveApiKey()`, `src/env.ts:20`) → keyring `global:opencode` → legacy keyring `rflectr` → oldest legacy service `opencode-starter`. On a successful legacy read, `migrateGlobalOpencodeCredential()` rewrites it to `global:opencode` using a read → write → verify → delete protocol — the old entry is deleted only after the new one verifies (`src/env.ts:217`–`src/env.ts:233`).
92
-
93
- ### Per-platform storage matrix
94
-
95
- `resolveOrCollectApiKey()` builds the option list per platform (`src/key-setup.ts:86`). The default selection is keychain/credential-manager/secret-service where available, else profile (`src/key-setup.ts:118`).
96
-
97
- | Platform | Options | Source |
98
- |----------|---------|--------|
99
- | **macOS** | Keychain only · Keychain + `~/.zshrc` (or profile) auto-load · shell profile (plaintext) · session only | `src/key-setup.ts:87`–`src/key-setup.ts:93` |
100
- | **Windows** | Windows Credential Manager · `setx` user env var (plaintext) · session only | `src/key-setup.ts:95`–`src/key-setup.ts:100` |
101
- | **Linux desktop** | Secret Service (GNOME Keyring / KWallet) · shell profile (plaintext) · session only | `src/key-setup.ts:103`–`src/key-setup.ts:111` |
102
- | **Linux headless** | shell profile · session only — shown with a note explaining why secure storage is unavailable | `src/key-setup.ts:105`–`src/key-setup.ts:111` |
103
-
104
- Notes grounded in code:
105
-
106
- - The macOS auto-load line uses the `security` CLI directly so the shell can source it (`src/key-setup.ts:143`): `export OPENCODE_API_KEY="$(security find-generic-password -s rflectr -a global:opencode -w 2>/dev/null)"`. It is appended only if not already present (`src/key-setup.ts:145`).
107
- - `setx` is invoked with piped stdio (`stdio: ['pipe','pipe','pipe']`) to suppress its "SUCCESS" stdout (`src/key-setup.ts:164`).
108
- - Secret Service availability is probed with a test `getPassword()` against a throwaway `rflectr-probe` entry (`isSecretServiceAvailable()`, `src/env.ts:367`); if the daemon isn't running the secure option is hidden and a note is shown (`src/key-setup.ts:103`–`src/key-setup.ts:107`).
109
- - `detectShellProfile()` chooses the right profile file per platform/shell — `~/.zshrc`, `~/.bash_profile`, `~/.bashrc`, or `~/.profile` (`src/key-setup.ts:21`).
110
- - The plaintext shell-profile path single-quotes and escapes the key before appending (`src/key-setup.ts:179`–`src/key-setup.ts:180`).
111
-
112
- ### Immediate session activation
113
-
114
- In every code path — found in store, freshly pasted, or save failed — `process.env['OPENCODE_API_KEY']` is set so the key is live for the current process: at the store-hit branch (`src/key-setup.ts:62`) and at the end of collection (`src/key-setup.ts:187`). This is the one documented mutation of the parent environment; `buildChildEnv()` otherwise only mutates the child (`src/env.ts:48`).
115
-
116
- ### Graceful degradation
117
-
118
- Every keyring operation goes through `readKeyringAccount` / `writeKeyringAccount` / `deleteKeyringAccount`, each wrapping a dynamic `import('@napi-rs/keyring')` in try/catch and routing failures through `classifyKeyringError()` into a `diag?` callback (`src/env.ts:139`–`src/env.ts:173`). A missing native binary therefore yields a warning ("native keyring module not available"), not a crash. `@napi-rs/keyring` ships as an `optionalDependency` and is marked `external` in the bundle so it resolves from `node_modules` at runtime.
119
-
120
- ### OAuth token storage
121
-
122
- OAuth tokens are stored in the same keyring under `oauth:provider:<id>` as a serialized `OpencodeOAuthCredential` JSON (`oauthCredentialToKeychainJson()`, `src/registry/opencode-auth.ts:110`). `authenticateProvider()` saves them via `saveProviderCredential(oauthAuthRef(registryId), …)` (`src/registry/provider-auth.ts:160`, `src/registry/provider-auth.ts:194`) and warns — without failing — if the write doesn't land. The broker path (`runOpencodeAuthBroker()`, `src/registry/auth-broker.ts:17`) delegates the actual login to `opencode auth login`, then reads the token back out of OpenCode's `auth.json` (`src/registry/opencode-auth.ts:80`). On resolution, OAuth JSON in a keyring account is decoded and, when near expiry, refreshed in place (`src/env.ts:290`, `src/env.ts:324`). The acquisition mechanics are PRD-007.
123
-
124
- ### Key validation before import
125
-
126
- `validateImportKey()` (`src/registry/validate-import-key.ts:30`) gates registry imports: OAuth providers pass through (`:34`); empty keys are rejected (`:38`); gcloud/AWS/Azure providers are flagged `untested-manual` (`:44`); otherwise the key is probed against the provider's real model endpoint and rejected as `placeholder-key` or `invalid-key` if the API refuses it (`:83`–`:107`). Placeholder keys are recognized by `isLikelyPlaceholderKey()` / `isPlaceholderProviderKey()` (`src/registry/refresh-credentials.ts:25`, `:30`), and a small env-fallback table lets `anthropic`/`openai` fall back to their standard SDK env vars when OpenCode supplied only a placeholder (`src/registry/refresh-credentials.ts:20`, `:56`).
127
-
128
- ---
129
-
130
- ## Security Considerations
131
-
132
- - **Plaintext options are opt-in and clearly labelled.** The `setx` and shell-profile choices write the key in cleartext; their prompt hints say so explicitly ("plaintext", "visible in System Properties → Environment Variables") (`src/key-setup.ts:91`, `src/key-setup.ts:98`, `src/key-setup.ts:109`). The default selection is always the secure store when available (`src/key-setup.ts:118`).
133
- - **Keyring is the default trust anchor.** Secrets live in the OS keyring by default; config files hold only `authRef` pointers (`src/env.ts:241`).
134
- - **The provider's real key never reaches the child when proxying** — the child gets a proxy token while the local proxy holds the real key (env contract, PRD-001 / PRD-005). Confirmed by `buildChildEnv()` setting `ANTHROPIC_API_KEY` to whatever caller passes — the proxy token on proxy routes (`src/env.ts:55`).
135
- - **What is never logged:** the key value itself is never written to the trace log. The trace path uses `writeSecureLogLine()` and logs only the *reason* string from a keyring diagnostic, never the secret (`src/key-setup.ts:52`–`src/key-setup.ts:56`). Dry-run output masks the value (`setx OPENCODE_API_KEY ***`, `src/key-setup.ts:128`). The interactive prompt uses `p.password()` so the paste is not echoed (`src/key-setup.ts:69`).
136
- - **Migration is non-destructive on failure.** The legacy entry is deleted only after the new `global:opencode` entry reads back identical; a verification mismatch keeps the legacy entry and warns (`src/env.ts:220`–`src/env.ts:224`).
137
- - **OAuth file permission hygiene.** When reading OpenCode's `auth.json`, a warning is emitted if the file is group/world-readable (`authFilePermissionWarning()`, `src/registry/opencode-auth.ts:66`).
138
- - **`--dry-run` writes nothing.** All persistence branches are skipped and replaced by `[dry-run]` log lines (`src/key-setup.ts:123`).
139
-
140
- ---
141
-
142
- ## Acceptance Criteria
143
-
144
- - [x] Secrets are stored in the OS keyring (or opt-in plaintext), never in `providers.json` / `config.json` — registry holds only `authRef` (`src/env.ts:241`).
145
- - [x] `@napi-rs/keyring` is loaded via dynamic `import()` and a missing native binary degrades gracefully without crashing (`src/env.ts:141`, `src/env.ts:155`).
146
- - [x] On startup, an existing key is read silently and no prompt is shown (`src/key-setup.ts:38`, `src/key-setup.ts:58`).
147
- - [x] macOS offers 4 save options (Keychain · Keychain + auto-load · profile · session) (`src/key-setup.ts:87`).
148
- - [x] Windows offers 3 save options (Credential Manager · `setx` · session) (`src/key-setup.ts:95`).
149
- - [x] Linux desktop offers Secret Service · profile · session; headless offers profile · session with an explanatory note (`src/key-setup.ts:103`).
150
- - [x] `process.env['OPENCODE_API_KEY']` is set immediately on resolve/collect regardless of save choice (`src/key-setup.ts:62`, `src/key-setup.ts:187`).
151
- - [x] Provider keys resolve via the documented order: namespaced env → `env:`-ref → `global:opencode` chain → per-provider keyring (`src/env.ts:241`).
152
- - [x] Legacy keyring entries migrate via read → write → verify → delete (`src/env.ts:199`).
153
- - [x] OAuth tokens are stored in the keychain and warn (not fail) on write failure (`src/registry/provider-auth.ts:160`, `:195`).
154
- - [x] Provider keys are validated against the live endpoint before import; placeholder/invalid keys are rejected (`src/registry/validate-import-key.ts:30`).
155
- - [x] The key value is never written to the trace log (only the diagnostic reason) and is masked in dry-run output (`src/key-setup.ts:55`, `src/key-setup.ts:128`).
156
- - [x] `--dry-run` performs no writes (`src/key-setup.ts:123`).
157
-
158
- ---
159
-
160
- ## Files
161
-
162
- | File | Role |
163
- |------|------|
164
- | `src/key-setup.ts` | Interactive Zen/Go key collection, per-platform save options, shell-profile detection, dry-run simulation |
165
- | `src/env.ts` | Credential store wrappers, `resolveProviderCredential`, `global:opencode` chain, migration, `classifyKeyringError`, `isSecretServiceAvailable`, `buildChildEnv` env contract |
166
- | `src/registry/auth-broker.ts` | Delegate OAuth login to OpenCode CLI, read token back from `auth.json` |
167
- | `src/registry/provider-auth.ts` | Native-vs-broker OAuth selector; saves tokens to keychain; upserts registry provider |
168
- | `src/registry/opencode-auth.ts` | Read/decode OpenCode `auth.json`; OAuth JSON (de)serialization; file-permission warning |
169
- | `src/registry/refresh-credentials.ts` | Placeholder-key detection; env-fallback table for refresh |
170
- | `src/registry/validate-import-key.ts` | Pre-import key validation against live endpoints |
171
- | `src/cli.ts` | Calls `resolveOrCollectApiKey` / `readGlobalOpencodeCredential` in the launch flow (`src/cli.ts:13`, `:888`) |
172
-
173
- ---
174
-
175
- ## Risks & Known Limitations
176
-
177
- - **Plaintext persistence is user-selectable.** `setx` and shell-profile options store the key in cleartext by design, for users without a working keyring. Mitigated by clear labelling and a secure default (`src/key-setup.ts:91`, `:98`, `:109`).
178
- - **Keyring dependency is optional and native.** If `@napi-rs/keyring` fails to load, no secure storage is available and the user is steered to session-only or plaintext (`src/env.ts:141`). The probe (`src/env.ts:367`) catches this on Linux before showing the option.
179
- - **OAuth broker requires the OpenCode CLI.** Providers without native OAuth and without OpenCode installed cannot complete broker login (`src/registry/auth-broker.ts:22`, `src/registry/provider-auth.ts:167`).
180
- - **gcloud/AWS/Azure providers are not importable by API key.** They are flagged `untested-manual` and must be configured via OpenCode env auth (`src/registry/validate-import-key.ts:44`).
181
- - **Server-mode exposure.** When the `server` command binds beyond localhost, its single password is the only gate — out of scope here, owned by PRD-012.
182
-
183
- ---
184
-
185
- ## Related
186
-
187
- - Knowledge: [`../../../knowledge/private/security/credential-storage.md`](../../../knowledge/private/security/credential-storage.md) — credential storage & environment isolation narrative.
188
- - [PRD-001 — CLI Core & Launch Orchestration](../prd-001-cli-core-launch-orchestration/prd-001-cli-core-launch-orchestration-index.md) — the `buildChildEnv()` env contract and scrubbed child environment.
189
- - [PRD-002 — Provider Registry](../prd-002-provider-registry/prd-002-provider-registry-index.md) — the registry that stores per-provider `authRef` pointers and triggers import-time validation.
190
- - [PRD-007 — OAuth Device Flows](../prd-007-oauth-device-flows/prd-007-oauth-device-flows-index.md) — the other credential path: how OAuth tokens are *acquired* before they land in the keychain documented here.
@@ -1,208 +0,0 @@
1
- # PRD-007: OAuth Device & PKCE Flows *(Retroactive)*
2
-
3
- > **Status:** Shipped
4
- > **Priority:** —
5
- > **Effort:** —
6
- > **Written:** June 2026
7
- > **Retroactive:** Yes — written after implementation (rflectr v0.2.7).
8
- > **Source:** `src/oauth/*`, `src/registry/refresh-credentials.ts`
9
-
10
- ---
11
-
12
- ## Overview
13
-
14
- Some model providers a developer might want to point Claude Code / Codex / Gemini at do not hand out a simple API key. ChatGPT (Plus/Pro), xAI SuperGrok, and GitHub Copilot authenticate the *user account* through OAuth, not a long-lived `sk-...` secret. Because rflectr runs in a terminal with no browser redirect URI to catch a callback, it uses the OAuth 2.0 **Device Authorization Grant** (RFC 8628): rflectr asks the provider for a user code, prints a verification URL, and polls the token endpoint until the user approves in their browser.
15
-
16
- This PRD documents the device-code login flows, the PKCE utilities that back them, the stored-credential shape, the lazy token-refresh path, and the static OAuth-derived model lists that stand in for the `/v1/models` endpoints those OAuth tokens cannot reach. It is the auth-acquisition counterpart to PRD-006 (credential storage), which owns where the resulting token lives.
17
-
18
- Knowledge doc: [`oauth-device-flows.md`](../../../knowledge/private/auth/oauth-device-flows.md).
19
-
20
- ---
21
-
22
- ## What Was Built
23
-
24
- - A shared PKCE/utility module (`src/oauth/pkce.ts`) — crypto-random verifier + SHA-256 challenge, OAuth `state` generation, and polling helpers (`positiveSecondsToMs`, `sleepMs`).
25
- - A stored-credential contract (`src/oauth/types.ts`) — `StoredOAuthCredential`, builders/parsers, time-based and proactive-JWT expiry checks, and the `supportsNativeOAuth()` gate over the native provider set `xai | xai-oauth | openai | openai-oauth | github-copilot`.
26
- - Three per-provider device-code flows:
27
- - **OpenAI / ChatGPT** (`src/oauth/openai.ts`) — usercode → poll → authorization-code exchange with PKCE `code_verifier`, plus `chatgpt_account_id` extraction.
28
- - **xAI / Grok** (`src/oauth/xai.ts`) — standard RFC 8628 device grant with `slow_down` handling.
29
- - **GitHub Copilot** (`src/oauth/github.ts`) — GitHub device flow → `ghu_` token → second exchange for a short-lived Copilot session token.
30
- - A single refresh dispatcher (`src/oauth/refresh.ts`) — `oauthCredentialShouldRefresh()` and `refreshStoredOAuthCredential()` routing to the right per-provider refresh.
31
- - Integration into credential resolution (`src/env.ts`) — OAuth tokens are read from the keyring account `oauth:provider:<id>`, refreshed lazily and deduplicated per account, and written back; a stale-but-valid token is reused if refresh fails.
32
- - Static OAuth model lists (`src/data/openai-oauth-models.ts`, `src/data/xai-oauth-models.ts`) — because the OAuth tokens are rejected by the providers' `/v1/models` endpoints.
33
- - Placeholder/env-fallback key resolution for the refresh-models path (`src/registry/refresh-credentials.ts`).
34
-
35
- ---
36
-
37
- ## Goals
38
-
39
- - Let a user sign into ChatGPT, SuperGrok, or GitHub Copilot from a terminal with no localhost callback server.
40
- - Acquire and persist OAuth tokens in a shape that PRD-006 credential storage can read transparently (an access token comes back from `oauth:provider:<id>` just like an API key would).
41
- - Keep short-lived access tokens fresh automatically, without re-prompting the user, by refreshing just-in-time at credential-resolution time.
42
- - Provide a usable model list for OAuth providers even though their tokens cannot call the standard model-discovery endpoint.
43
- - Make refresh robust to transient failures (reuse a still-valid token rather than hard-failing a launch).
44
-
45
- ## Non-Goals
46
-
47
- - A localhost redirect-URI / authorization-code-in-browser flow — device code is the only path (`src/oauth/openai.ts:51`, `xai.ts:115`, `github.ts:149`).
48
- - Making GitHub Copilot usable as a *model provider*. Copilot OAuth *login* is implemented, but Copilot-as-backend is out of scope (OpenCode loads `@ai-sdk/github-copilot` from internal `@opencode-ai/core`, not a public npm factory).
49
- - Live model discovery for OAuth providers — replaced by static seed lists (`src/data/openai-oauth-models.ts:7`, `xai-oauth-models.ts:6`).
50
- - Owning the keyring/OS-credential-store mechanics (that is PRD-006).
51
-
52
- ---
53
-
54
- ## Features
55
-
56
- | Feature | Provider(s) | Module | Notes |
57
- | --- | --- | --- | --- |
58
- | Device-code login (RFC 8628) | OpenAI, xAI, GitHub Copilot | `openai.ts`, `xai.ts`, `github.ts` | user code + verification URL printed, then poll |
59
- | PKCE verifier/challenge | OpenAI (authorization-code exchange) | `pkce.ts:22` | `challenge = base64url(SHA-256(verifier))` |
60
- | OAuth `state` generation | shared utility | `pkce.ts:28` | base64url of 32 random bytes |
61
- | `slow_down` / `authorization_pending` poll handling | OpenAI, xAI, GitHub | all three flows | interval bumped +5 s on `slow_down` (xAI/GitHub) |
62
- | Account-id extraction | OpenAI | `openai.ts:20` | `chatgpt_account_id` from JWT → routes to ChatGPT Codex backend |
63
- | Two-step token exchange | GitHub Copilot | `github.ts:57` | `ghu_` → short-lived Copilot session token |
64
- | Token refresh | OpenAI, xAI, GitHub | `refresh.ts:21` | `grant_type=refresh_token` (OpenAI/xAI), re-exchange `ghu_` (GitHub) |
65
- | Proactive JWT-expiry refresh | native providers | `types.ts:53` | decode `exp`, refresh before expiry |
66
- | Static OAuth model list | OpenAI, xAI | `openai-oauth-models.ts`, `xai-oauth-models.ts` | substitutes for `/v1/models` |
67
- | Refresh-time placeholder/env-fallback key | OpenCode-imported providers | `refresh-credentials.ts:56` | env fallback for `openai`/`anthropic` |
68
-
69
- ---
70
-
71
- ## Architecture & Implementation
72
-
73
- ### Why device flow
74
-
75
- These hosts run in a terminal with no browser redirect URI to catch a callback. The Device Authorization Grant fits: request a user code, print a URL, poll until approval. `supportsNativeOAuth(providerId)` (`src/oauth/types.ts:71`) gates which providers offer this; the native set is defined in `NATIVE_OAUTH_PROVIDER_IDS` (`src/oauth/types.ts:68`). The `rflectr providers auth <id>` command drives it (`runProvidersAuth`, `src/providers-command.ts:221`).
76
-
77
- ### Device-code flow steps (generic)
78
-
79
- ```mermaid
80
- sequenceDiagram
81
- participant U as User
82
- participant R as rflectr
83
- participant P as Provider auth server
84
- R->>P: request device / user code
85
- P-->>R: user_code + verification URL + interval
86
- R->>U: "visit <url>, enter <user_code>"
87
- loop until approved (respect interval / slow_down)
88
- R->>P: poll token endpoint
89
- P-->>R: authorization_pending | slow_down | tokens
90
- end
91
- P-->>R: access + refresh tokens
92
- R->>R: store StoredOAuthCredential at keyring account oauth:provider:<id>
93
- ```
94
-
95
- The shared `runXxxDeviceCodeFlow(onDeviceCode, opts?)` signature (`openai.ts:51`, `xai.ts:115`, `github.ts:149`) calls back with `{ url, userCode }` so the CLI can print the prompt, then polls. `opts.sleep` / `opts.now` are injectable for tests. Each poller computes a `deadline` from the device response `expires_in` (defaulting to 5 min for OpenAI/xAI, 15 min for GitHub) and a per-iteration `intervalMs` from the response `interval`, capping each sleep at the remaining time (`xai.ts:64`, `github.ts:101`, `openai.ts:77`).
96
-
97
- ### Per-provider differences
98
-
99
- | Aspect | OpenAI / ChatGPT | xAI / Grok | GitHub Copilot |
100
- | --- | --- | --- | --- |
101
- | Client id | `app_EMoamEEZ73f0CkXaXp7hrann` (`openai.ts:9`) | `b1a00492-073a-47ea-816f-4c329264a828` (`xai.ts:9`) | `Iv1.b507a08c87ecfe98` — VS Code Copilot extension id (`github.ts:13`) |
102
- | Device endpoint | `…/api/accounts/deviceauth/usercode` (`openai.ts:58`) | `https://auth.x.ai/oauth2/device/code` (`xai.ts:11`) | `https://github.com/login/device/code` (`github.ts:14`) |
103
- | Poll endpoint | `…/api/accounts/deviceauth/token` (`openai.ts:82`) | `https://auth.x.ai/oauth2/token` (`xai.ts:10`) | `https://github.com/login/oauth/access_token` (`github.ts:15`) |
104
- | Grant on poll | proprietary device-auth, then `authorization_code` exchange at `/oauth/token` with PKCE `code_verifier` (`openai.ts:96`) | `urn:ietf:params:oauth:grant-type:device_code` (`xai.ts:12`) | `urn:ietf:params:oauth:grant-type:device_code` (`github.ts:114`) |
105
- | Scope | (none on device request; `client_id` only) | `openid profile email offline_access grok-cli:access api:access` (`xai.ts:13`) | `copilot` (`github.ts:17`) |
106
- | PKCE used | Yes — `code_verifier` returned by device-auth, sent on exchange (`openai.ts:104`) | No (standard device grant) | No (standard device grant) |
107
- | Pending signal | HTTP 403/404 from token poll (`openai.ts:114`) | `error: authorization_pending` JSON (`xai.ts:84`) | `error: authorization_pending` JSON (`github.ts:132`) |
108
- | `slow_down` handling | (margin only; no interval bump) | interval += 5 s (`xai.ts:88`) | interval += 5 s (`github.ts:136`) |
109
- | Second exchange | none | none | `ghu_` → Copilot session token at `…/copilot_internal/v2/token` (`github.ts:57`) |
110
- | Stored `refresh` field | provider refresh token | provider refresh token | the long-lived `ghu_` token itself (`github.ts:91`) |
111
- | Account id | `chatgpt_account_id` from JWT (`openai.ts:20`) | — | — |
112
-
113
- The GitHub two-step is the quirk to remember: the standard device flow yields a long-lived `ghu_` access token, then `exchangeForCopilotToken(ghuToken)` GETs `…/copilot_internal/v2/token` to mint a **short-lived** Copilot session token (`github.ts:57`). Because the session token is what's used at inference time, the **stored `refresh` field is the `ghu_` itself** (`github.ts:91`); `refreshGithubCopilotToken()` just re-exchanges it (`github.ts:87`).
114
-
115
- ### PKCE & utilities (`src/oauth/pkce.ts`)
116
-
117
- - `generatePkce()` → `{ verifier, challenge }` with `challenge = base64url(SHA-256(verifier))` and a 64-char verifier from the unreserved-char set (`pkce.ts:22`).
118
- - `generateOAuthState()` → base64url of 32 random bytes (`pkce.ts:28`).
119
- - `generateRandomString(length)` — crypto-random from `A-Za-z0-9-._~` (`pkce.ts:10`).
120
- - `positiveSecondsToMs(value, defaultMs)`, `sleepMs(ms)` — polling helpers (`pkce.ts:34`, `pkce.ts:39`).
121
-
122
- ### Stored credential shape (`src/oauth/types.ts`)
123
-
124
- `StoredOAuthCredential` aliases OpenCode's `OpencodeOAuthCredential` (`types.ts:7`) — `{ type: 'oauth', access, refresh, expires, accountId? }`. It is serialized to JSON and written to the keyring account `oauth:provider:<id>` (`oauthProviderKeyringAccount`, `src/env.ts:100`). Helpers:
125
-
126
- - `tokensToStoredCredential(tokens, existingRefresh?, accountId?)` — builds it from a token response, preserving the existing refresh token when the provider didn't return a new one, and defaulting `expires` to now + 1 h when `expires_in` is absent (`types.ts:16`).
127
- - `parseStoredOAuthCredential(raw)` — parse + validate from the keyring (`types.ts:30`).
128
- - `oauthCredentialNeedsRefresh(cred, skewMs?)` — true when `expires <= now + skew` (default 120 s, `types.ts:48`).
129
- - `accessTokenIsExpiring(token, skewMs?)` — best-effort decode of the JWT `exp` claim; opaque tokens return `false` (no proactive refresh) (`types.ts:53`).
130
-
131
- ### Refresh orchestration (`src/oauth/refresh.ts` + `src/env.ts`)
132
-
133
- `oauthCredentialShouldRefresh(cred, providerId)` is true when `oauthCredentialNeedsRefresh(cred)` (time-based) **or**, for native providers, `accessTokenIsExpiring(cred.access)` (proactive JWT check) (`refresh.ts:11`). `refreshStoredOAuthCredential(providerId, cred)` dispatches to `refreshOpenAiAccessToken` / `refreshXaiAccessToken` / `refreshGithubCopilotToken` and rebuilds a `StoredOAuthCredential` via `tokensToStoredCredential`, throwing if no refresh token is present (`refresh.ts:21`).
134
-
135
- Refresh runs lazily at credential-resolution time. `readProviderSecret()` (`src/env.ts:324`) detects a JSON OAuth blob at an `oauth:provider:*` account and routes to `refreshOAuthKeyringAccount()` (`src/env.ts:290`), which:
136
-
137
- - Deduplicates per keyring account via an in-flight `Map` (`src/env.ts:296`, `oauthRefreshInflight` at `src/env.ts:109`) so concurrent launches don't double-refresh.
138
- - Writes the refreshed credential back to the keyring (`src/env.ts:307`).
139
- - **On refresh failure**, reuses the existing access token if it hasn't yet expired (`src/env.ts:311`), otherwise rethrows.
140
-
141
- The decoded *access token* (not the JSON blob) is what `resolveProviderCredential()` ultimately returns to callers (`decodeProviderSecret`, `src/env.ts:274`), so OAuth providers look identical to API-key providers downstream.
142
-
143
- ### OAuth-derived model lists (PRD-003 cross-reference)
144
-
145
- OAuth tokens cannot call the providers' `/v1/models`:
146
-
147
- - **OpenAI** — the ChatGPT OAuth backend (`chatgpt.com/backend-api/codex`) has no `GET /v1/models`, and `auth.openai.com` access tokens are rejected by `api.openai.com/v1/models` (`openai-oauth-models.ts:7`). `buildOpenAiOAuthModels()` returns a static seed list of GPT/o-series models with `npm '@ai-sdk/openai'` (`openai-oauth-models.ts:56`). `CHATGPT_CODEX_UNSUPPORTED_MODELS` filters out models the Codex backend explicitly rejects (currently `gpt-5.5-fast`) (`openai-oauth-models.ts:34`).
148
- - **xAI** — the SuperGrok OAuth JWT is rejected by `api.x.ai/v1/models` with 401, so `buildXaiOAuthModels()` is a fallback seed list with `npm '@ai-sdk/xai'` (`xai-oauth-models.ts:6`, `xai-oauth-models.ts:40`).
149
-
150
- Both lists are consumed by the registry refresh path (`src/registry/refresh-models.ts:186`, `:226`).
151
-
152
- ### Refresh-time key resolution (`src/registry/refresh-credentials.ts`)
153
-
154
- OpenCode imports often carry placeholder keys (`anything`, `ollama`, `none`, …) because OAuth/env supplies the real credential at runtime. `isPlaceholderProviderKey()` / `isLikelyPlaceholderKey()` detect those (`refresh-credentials.ts:25`, `:30`), and `resolveRefreshCredential()` falls back to an env var (`OPENAI_API_KEY`, `ANTHROPIC_API_KEY`) when the stored key looks like a placeholder (`refresh-credentials.ts:56`).
155
-
156
- ---
157
-
158
- ## Acceptance Criteria
159
-
160
- - [x] Device-code login is implemented for OpenAI, xAI, and GitHub Copilot with a `{ url, userCode }` callback for the CLI prompt (`openai.ts:51`, `xai.ts:115`, `github.ts:149`).
161
- - [x] PKCE verifier/challenge generation exists and the OpenAI flow exchanges the authorization code with a `code_verifier` (`pkce.ts:22`, `openai.ts:104`).
162
- - [x] Pollers honor the device `interval`, handle `authorization_pending` and `slow_down` (interval +5 s where applicable), and time out at the device `expires_in` deadline (`xai.ts:70`, `github.ts:107`, `openai.ts:81`).
163
- - [x] The GitHub two-step exchange mints a short-lived Copilot session token and stores the `ghu_` token as the refresh field (`github.ts:57`, `github.ts:91`).
164
- - [x] OpenAI account id (`chatgpt_account_id`) is extracted from the JWT for backend routing (`openai.ts:20`).
165
- - [x] `StoredOAuthCredential` is built/parsed/validated and persisted to keyring account `oauth:provider:<id>` (`types.ts:16`, `types.ts:30`, `env.ts:100`).
166
- - [x] Refresh is dispatched per provider and is both time-based and proactively JWT-based for native providers (`refresh.ts:11`, `refresh.ts:21`, `types.ts:53`).
167
- - [x] Refresh is deduplicated per keyring account, written back, and falls back to a stale-but-valid token on failure (`env.ts:290`, `env.ts:307`, `env.ts:311`).
168
- - [x] Static OAuth model lists exist for OpenAI and xAI, with Codex-unsupported models filtered (`openai-oauth-models.ts:56`, `xai-oauth-models.ts:40`, `openai-oauth-models.ts:34`).
169
- - [x] Placeholder-key detection with env fallback is implemented for the refresh-models path (`refresh-credentials.ts:25`, `refresh-credentials.ts:56`).
170
- - [x] Flows are unit-tested with injectable `sleep`/`now` (`tests/oauth.test.ts`, `tests/oauth-openai.test.ts`, `tests/oauth-github.test.ts`).
171
-
172
- ---
173
-
174
- ## Files
175
-
176
- | File | Role |
177
- | --- | --- |
178
- | `src/oauth/pkce.ts` | PKCE verifier/challenge, OAuth `state`, polling helpers |
179
- | `src/oauth/types.ts` | `StoredOAuthCredential`, builders/parsers, expiry checks, `supportsNativeOAuth` |
180
- | `src/oauth/openai.ts` | OpenAI ChatGPT device-code flow + PKCE exchange + account-id + refresh |
181
- | `src/oauth/xai.ts` | xAI SuperGrok device-code flow + refresh |
182
- | `src/oauth/github.ts` | GitHub Copilot device flow + two-step Copilot-token exchange + refresh |
183
- | `src/oauth/refresh.ts` | Single refresh dispatcher (`oauthCredentialShouldRefresh`, `refreshStoredOAuthCredential`) |
184
- | `src/registry/refresh-credentials.ts` | Placeholder-key detection + env-fallback for refresh-models |
185
- | `src/data/openai-oauth-models.ts` | Static ChatGPT-OAuth model seed list + Codex-unsupported filter |
186
- | `src/data/xai-oauth-models.ts` | Static SuperGrok-OAuth model fallback seed list |
187
- | `src/env.ts` (integration) | OAuth read/refresh/write-back at `oauth:provider:<id>`, dedup, stale-token fallback |
188
- | `src/providers-command.ts` (integration) | `rflectr providers auth <id>` entry point |
189
-
190
- ---
191
-
192
- ## Risks & Known Limitations
193
-
194
- - **OAuth providers with no stored key are silently skipped in some local-provider paths.** `normalizeProviders` skips providers whose `key` field is empty, which is exactly how OAuth-only providers (configured via browser login, no `sk-...`) appear. This is documented as by-design in the project notes; rflectr's own native OAuth path (`oauth:provider:<id>`) is the supported way to use these accounts.
195
- - **GitHub Copilot works as a login but not as a model provider** — OpenCode loads `@ai-sdk/github-copilot` from internal `@opencode-ai/core`, not a public npm factory rflectr can ship.
196
- - **No live model discovery for OAuth providers** — model lists are static seeds (`src/data/*-oauth-models.ts`) and must be hand-updated as providers change offerings. Codex-rejected models are filtered by a hard-coded set (`CHATGPT_CODEX_UNSUPPORTED_MODELS`).
197
- - **Proactive refresh only works for JWT access tokens** — `accessTokenIsExpiring()` returns `false` for opaque tokens, so those rely solely on the time-based `expires` field (`types.ts:53`).
198
- - **`::ts::`-style or other separator collisions** are not a concern here, but the `ghu_`-as-refresh design means a revoked GitHub OAuth grant surfaces only at the next Copilot-token exchange.
199
- - **Best-effort error reuse** — if refresh fails and the access token is already expired, the launch fails hard (`env.ts:312`); there is no interactive re-auth prompt at launch time.
200
-
201
- ---
202
-
203
- ## Related
204
-
205
- - [`oauth-device-flows.md`](../../../knowledge/private/auth/oauth-device-flows.md) — knowledge doc this PRD is grounded in.
206
- - [PRD-006 — Credential Storage & API Key Management](../prd-006-credential-storage/prd-006-credential-storage-index.md) — owns the OS keyring / credential store where OAuth tokens live.
207
- - [PRD-002 — Provider Registry](../prd-002-provider-registry/prd-002-provider-registry-index.md) — how an OAuth provider is registered and resolved (`authRef` = `keyring:oauth:provider:<id>`).
208
- - [PRD-003 — Model Discovery & Classification](../prd-003-model-discovery-classification/prd-003-model-discovery-classification-index.md) — consumes the static OAuth model seed lists.