aisubs 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ ## 0.3.1 - 2026-08-20
4
+
5
+ - Add dashboard-managed Codex Desktop integration with callable model discovery,
6
+ `provider/model` routing, and an option to restore the official Codex provider.
7
+ - Harden concurrent token refresh, reauthentication account selection, credential
8
+ file validation, Realtime startup, and development-server shutdown behavior.
9
+ - Fix stale dashboard data, failed-action feedback, provider-specific examples,
10
+ Codex configuration errors, model deduplication, and protocol compatibility
11
+ edge cases.
12
+ - Remove redundant tests and unsafe internal type erasure while retaining focused
13
+ regression coverage for the corrected behavior.
14
+
15
+ ## 0.3.0 - 2026-08-16
16
+
17
+ - Add a universal OpenAI-compatible Chat Completions surface with translation
18
+ to supported native Responses, Anthropic Messages, and Google
19
+ `generateContent` endpoints.
20
+ - Add native OpenAI Realtime WebSocket tunnelling and account-scoped model
21
+ discovery with capability metadata.
22
+ - Add Claude, OpenCode Go, and OpenCode Zen to the dashboard and examples;
23
+ improve provider model metadata and usage reporting.
24
+ - Document desktop-client and SDK setup, local-key handling, compatibility
25
+ behavior, and cross-protocol streaming limits.
26
+
27
+ ## 0.2.0 - 2026-08-15
28
+
29
+ - Previous release.
package/README.md CHANGED
@@ -6,13 +6,27 @@
6
6
  </table>
7
7
 
8
8
  AISubs connects provider accounts once and exposes each account through a local,
9
- account-scoped API. Use it from the official OpenAI or Anthropic SDK, Vercel AI SDK,
10
- TanStack AI, Python, cURL, or an app that accepts a custom API base URL.
9
+ account-scoped API. Every generative account has an OpenAI-compatible surface,
10
+ while native Responses, Anthropic Messages, Google `generateContent`, Realtime,
11
+ and provider-specific endpoints remain available. Use it from an SDK, cURL, or
12
+ an app that accepts a custom API base URL.
11
13
 
12
14
  <p align="center">
13
15
  <img src="./public/aisubs-dashboard.png" alt="AI Subs dashboard with provider connections and local API access" width="100%" />
14
16
  </p>
15
17
 
18
+ <p align="center">
19
+ <img src="./public/aisubs-chatgpt-account.png" alt="AISubs ChatGPT demo account details" width="100%" />
20
+ </p>
21
+
22
+ <p align="center">
23
+ <img src="./public/aisubs-copilot-account.png" alt="AISubs GitHub Copilot demo account details" width="100%" />
24
+ </p>
25
+
26
+ <p align="center">
27
+ <img src="./public/aisubs-grok-account.png" alt="AISubs Grok demo account details" width="100%" />
28
+ </p>
29
+
16
30
  > Credentials, API keys, and requests remain on your computer. AISubs collects no
17
31
  > telemetry or analytics. The dashboard keeps up to 200 redacted account request logs in memory for debugging.
18
32
 
@@ -35,6 +49,11 @@ key: reveal it, copy it, or deliberately regenerate it there.
35
49
  The default URL is `http://127.0.0.1:4319`. Credentials and the API key are stored
36
50
  under `~/.aisubs` and reused on later starts.
37
51
 
52
+ Supported subscription/account providers are ChatGPT, Claude, GitHub Copilot,
53
+ Grok, OpenCode Go, and OpenCode Zen. Google `generateContent` is a supported
54
+ wire protocol only for Google models surfaced by one of those connected
55
+ providers; AISubs does not connect or claim a Google subscription account.
56
+
38
57
  ## Use an account from any compatible app
39
58
 
40
59
  Open an account in the dashboard and copy its base URL:
@@ -61,37 +80,128 @@ export OPENAI_API_KEY="aisubs_..."
61
80
  AISubs removes its local key before forwarding a request and adds only the
62
81
  selected account's provider credential.
63
82
 
64
- ### Compatibility contract
83
+ ## Desktop app examples
84
+
85
+ Keep AISubs running, then give the app the three values from the connected
86
+ account: its base URL, the AISubs API key, and an exact model ID.
87
+
88
+ ### [Handy](https://handy.computer/download) (macOS)
89
+
90
+ Open **Post Process** and set:
91
+
92
+ | Handy field | Value |
93
+ | ----------- | -------------------------------------------------------- |
94
+ | Provider | `Custom` |
95
+ | Base URL | `http://127.0.0.1:4319/aisubs/chatgpt/default/v1` |
96
+ | API Key | Your `aisubs_...` key from the AISubs dashboard |
97
+ | Model | An exact Dashboard model ID, for example `gpt-5.6-terra` |
98
+
99
+ Click Handy’s refresh button beside **Model**, select the model, then choose a
100
+ prompt and test it. Replace `chatgpt/default` with the provider and account
101
+ segment copied from your AISubs dashboard when using a different account.
102
+
103
+ ### [Raycast](https://www.raycast.com/download) AI custom provider
104
+
105
+ Create or edit `~/.config/raycast/ai/providers.yaml` and add a provider like
106
+ this. Keep the API key private—use the value from your AISubs dashboard, never
107
+ one copied from an example or screenshot.
108
+
109
+ ```yaml
110
+ providers:
111
+ - id: ai-subs
112
+ name: AI Subs
113
+ base_url: http://127.0.0.1:4319/aisubs/chatgpt/default/v1
114
+ api_keys:
115
+ default: aisubs_REPLACE_WITH_YOUR_DASHBOARD_KEY
116
+ models:
117
+ - id: gpt-5.6-terra
118
+ name: AISubs GPT-5.6 Terra
119
+ context: 272000
120
+ description: ChatGPT subscription via AISubs
121
+ abilities:
122
+ vision:
123
+ supported: true
124
+ system_message:
125
+ supported: true
126
+ tools:
127
+ supported: true
128
+ reasoning_effort:
129
+ supported: true
130
+ ```
131
+
132
+ Restart Raycast after saving. Replace the base URL, model ID, context, and
133
+ abilities with the values shown for your connected account. Do not enable an
134
+ ability that the selected model does not report.
135
+
136
+ [Cline](https://cline.bot/) also accepts a custom OpenAI-compatible endpoint;
137
+ use the same base URL, local AISubs key, and model ID. Exact settings labels can
138
+ change between client releases, so the account dashboard remains the source of
139
+ truth for the three values.
140
+
141
+ ### Codex Desktop
142
+
143
+ AISubs can make callable models from connected non-ChatGPT accounts available in
144
+ Codex Desktop through one local Responses endpoint. Keep AISubs running, connect
145
+ the accounts you want to use, then:
146
+
147
+ 1. Open **Configure Codex** on the AISubs dashboard.
148
+ 2. Choose **Use AISubs models in Codex**.
149
+ 3. Fully restart Codex Desktop.
150
+ 4. Select a model named `provider/model`, such as `copilot/MODEL_ID`.
151
+
152
+ Configuration writes `~/.codex/aisubs-catalog.json` and updates the root settings,
153
+ AISubs provider, and `AISUBS_API_KEY` entry in `~/.codex/config.toml`. Both files
154
+ are created with user-private permissions when they do not already exist. The key
155
+ is stored in that Codex config so Codex can authenticate to the localhost AISubs
156
+ server; do not share the file.
157
+
158
+ The generated catalog intentionally excludes ChatGPT models so Codex continues to
159
+ treat official OpenAI models as native when official mode is active. Choose
160
+ **Restore official Codex models** in the same dialog, then restart Codex Desktop,
161
+ to remove the AISubs provider and catalog selection. AISubs saves the pre-restore
162
+ config as `~/.codex/config.toml.aisubs-backup` if that backup does not already
163
+ exist.
164
+
165
+ If multiple connected accounts expose the same `provider/model`, the router uses
166
+ the first connected account that reports it. Regenerating the AISubs API key
167
+ invalidates the old Codex configuration; run **Configure Codex** again and restart
168
+ Codex Desktop. Configuration refuses to overwrite the catalog when no callable
169
+ non-ChatGPT model can be discovered.
65
170
 
66
- AISubs preserves the provider's native request and response protocol. It also
67
- provides a focused, non-streaming text Chat Completions adapter for ChatGPT
68
- accounts so apps such as [Handy](https://github.com/cjpais/Handy) can use them.
69
-
70
- | Provider/model protocol | Base URL | Supported request path |
71
- | ---------------------------------- | --------------------------- | --------------------------------------- |
72
- | OpenAI Responses | Account URL ending in `/v1` | `POST /responses` |
73
- | OpenAI-compatible Chat Completions | Account URL ending in `/v1` | `POST /chat/completions` |
74
- | Anthropic Messages | Account URL ending in `/v1` | `POST /messages` |
75
- | Google generateContent | Account URL ending in `/v1` | `POST /models/MODEL_ID:generateContent` |
76
- | Model discovery | Account URL ending in `/v1` | `GET /models` |
77
-
78
- Embeddings are intentionally not exposed. The ChatGPT compatibility adapter
79
- supports text messages and JSON-schema response formats; tools, images, audio,
80
- and streaming Chat Completions remain native-Responses-only features.
81
-
82
- ### Handy
83
-
84
- Choose **Custom** in Handy and copy these values from the ChatGPT account page:
85
-
86
- ```text
87
- Base URL: http://127.0.0.1:4319/aisubs/chatgpt/ACCOUNT/v1
88
- API key: the persistent key shown on the AISubs dashboard
89
- Model: an exact model ID shown for the account
90
- ```
171
+ ### Compatibility contract
91
172
 
92
- Handy sends `POST /chat/completions` with streaming disabled. AISubs translates
93
- that request to ChatGPT Responses and returns the `choices[0].message.content`
94
- shape Handy reads.
173
+ Use Chat Completions when an app offers only an “OpenAI-compatible” provider.
174
+ AISubs discovers the selected model's native protocol and translates the
175
+ request and response when necessary. Calling a model's native protocol remains
176
+ a pass-through, which preserves provider-specific fields and streaming events.
177
+
178
+ | Integration path | Example full URL | Request example |
179
+ | ---------------------------------- | -------------------------------------------------------------------------------------- | --------------------------------------- |
180
+ | OpenAI Responses | `http://127.0.0.1:4319/aisubs/chatgpt/default/v1/responses` | `POST /responses` |
181
+ | OpenAI-compatible Chat Completions | `http://127.0.0.1:4319/aisubs/chatgpt/default/v1/chat/completions` | `POST /chat/completions` |
182
+ | Anthropic Messages | `http://127.0.0.1:4319/aisubs/claude/default/v1/messages` | `POST /messages` |
183
+ | Google generateContent | `http://127.0.0.1:4319/aisubs/opencode-zen/default/v1/models/MODEL_ID:generateContent` | `POST /models/MODEL_ID:generateContent` |
184
+ | Model discovery | `http://127.0.0.1:4319/aisubs/chatgpt/default/v1/models` | `GET /models` |
185
+ | Model details | `http://127.0.0.1:4319/aisubs/chatgpt/default/v1/models/MODEL_ID` | `GET /models/MODEL_ID` |
186
+ | OpenAI Realtime | `ws://127.0.0.1:4319/aisubs/PROVIDER/ACCOUNT/v1/realtime?model=MODEL_ID` | WebSocket |
187
+
188
+ Replace the provider, account (`default`), and model ID with the values shown
189
+ for your connected account in the AISubs dashboard.
190
+
191
+ Cross-protocol translation covers text and system messages, streaming, function
192
+ tools and tool results, image/file/audio input where both protocols support it,
193
+ JSON-schema output, reasoning effort, stop conditions, and usage including
194
+ cached and reasoning tokens. If a feature has no safe equivalent, AISubs returns
195
+ an explicit `unsupported_feature` error instead of silently dropping it.
196
+
197
+ Provider-native routes—including embeddings, image/audio generation, batches,
198
+ files, and Realtime—are forwarded when that connected provider exposes them.
199
+ AISubs cannot add a capability that the provider, subscription, or selected
200
+ model does not have. Native requests preserve provider-specific cache controls;
201
+ translated requests preserve shared fields and cache-usage counters. Other than
202
+ Responses-to-Chat streaming, cross-protocol streams may be emitted after the
203
+ native response completes; use the native endpoint when event-level streaming
204
+ or provider-specific fields matter.
95
205
 
96
206
  ## SDK examples
97
207
 
@@ -102,7 +212,7 @@ export AISUBS_API_KEY="aisubs_..."
102
212
  ```
103
213
 
104
214
  <details>
105
- <summary><strong>Official OpenAI JavaScript SDK Responses and Chat Completions</strong></summary>
215
+ <summary><strong>Official OpenAI JavaScript SDK: Responses and Chat Completions</strong></summary>
106
216
 
107
217
  Install:
108
218
 
@@ -131,8 +241,8 @@ const stream = await client.responses.create({
131
241
  for await (const event of stream) console.log(event);
132
242
  ```
133
243
 
134
- Chat Completions uses the same client with an account/model that reports that
135
- endpoint:
244
+ Chat Completions uses the same client with any generative model. AISubs
245
+ translates to the selected model's native protocol when needed:
136
246
 
137
247
  ```js
138
248
  const response = await client.chat.completions.create({
@@ -206,16 +316,17 @@ const result = streamText({
206
316
  for await (const text of result.textStream) process.stdout.write(text);
207
317
  ```
208
318
 
209
- For Chat Completions, install `@ai-sdk/openai-compatible`, create the provider
210
- with the same account base URL, and select the model with `provider("MODEL_ID")`.
319
+ For the universal Chat Completions surface, install
320
+ `@ai-sdk/openai-compatible`, create the provider with the same account base URL,
321
+ and select the model with `provider("MODEL_ID")`.
211
322
 
212
323
  </details>
213
324
 
214
325
  <details>
215
- <summary><strong>TanStack AI Chat Completions</strong></summary>
326
+ <summary><strong>TanStack AI: Chat Completions</strong></summary>
216
327
 
217
- TanStack's generic compatibility adapter targets Chat Completions. Use it only
218
- for a model that lists `chat/completions`.
328
+ TanStack's generic compatibility adapter targets Chat Completions. AISubs
329
+ translates it to the selected model's native protocol.
219
330
 
220
331
  ```bash
221
332
  nub install @tanstack/ai @tanstack/ai-openai
@@ -271,7 +382,7 @@ print(response.choices[0].message.content)
271
382
  </details>
272
383
 
273
384
  <details>
274
- <summary><strong>cURL Responses, Chat Completions, Anthropic, and Google</strong></summary>
385
+ <summary><strong>cURL: Responses, Chat Completions, Anthropic, and Google</strong></summary>
275
386
 
276
387
  ```bash
277
388
  curl "http://127.0.0.1:4319/aisubs/chatgpt/personal/v1/responses" \
@@ -313,7 +424,9 @@ curl "http://127.0.0.1:4319/aisubs/opencode-zen/lab/v1/models/MODEL_ID_FROM_DASH
313
424
  ## Direct AISubs SDK
314
425
 
315
426
  Use the in-process SDK when AISubs is part of your trusted Node.js backend. It
316
- needs no local server or AISubs API key.
427
+ needs no local server, AISubs API key, or app-specific integration. Direct SDK
428
+ requests use the provider-native endpoint; the local server is the surface that
429
+ adds cross-protocol compatibility for third-party clients.
317
430
 
318
431
  Install the package in a project:
319
432
 
@@ -337,8 +450,12 @@ if (!(await account.status()).authenticated) {
337
450
  }
338
451
 
339
452
  const catalog = await account.getModels();
340
- const model = catalog?.models.find((item) => item.selectable !== false)?.id;
341
- if (!model) throw new Error("No selectable model is available");
453
+ const selected = catalog?.models.find((item) => item.selectable !== false);
454
+ if (!selected) throw new Error("No selectable model is available");
455
+
456
+ // ChatGPT models are native Responses models. Other providers report their
457
+ // native endpoint in selected.endpoints; use the matching native SDK adapter.
458
+ const model = selected.id;
342
459
 
343
460
  const response = await account.proxy("responses", {
344
461
  method: "POST",
@@ -354,6 +471,11 @@ Available provider factories are `chatGptProvider()`, `claudeProvider()`,
354
471
  `copilotProvider()`, `grokProvider()`, `openCodeGoProvider()`, and
355
472
  `openCodeZenProvider()`.
356
473
 
474
+ `getModels()` is cached for five minutes and `getUsage()` for 15 seconds by
475
+ default, with concurrent calls deduplicated. Account changes invalidate both.
476
+ Pass `modelsCacheTtlMs` or `usageCacheTtlMs` to `createSubscriptionAuth()` when
477
+ an in-process application needs different metadata freshness.
478
+
357
479
  Useful account methods:
358
480
 
359
481
  | Method | Purpose |
@@ -536,17 +658,26 @@ GET /v1/usage/:provider?account=work
536
658
  GET /v1/models/:provider?account=work
537
659
  GET /v1/api-key # dashboard session only
538
660
  POST /v1/api-key/regenerate # dashboard session only
661
+ POST /v1/codex/configure # dashboard session only
662
+ POST /v1/codex/restore-official # dashboard session only
663
+ GET /aisubs-codex/v1/models
664
+ POST /aisubs-codex/v1/responses
539
665
  * /aisubs/:provider/:account/v1/*
540
666
  ```
541
667
 
542
- The provider and account routes require `Authorization: Bearer AISUBS_API_KEY`
543
- or `x-api-key: AISUBS_API_KEY`. Regenerating the key immediately invalidates the
544
- old key.
668
+ The provider and account routes accept `Authorization: Bearer AISUBS_API_KEY`
669
+ or `x-api-key: AISUBS_API_KEY`. A Google-protocol client can instead put that
670
+ same local AISubs key in `x-goog-api-key` or `?key=`; this does not represent a
671
+ Google credential or subscription. Local credentials are removed from headers
672
+ and query strings before proxying. Regenerating the key immediately invalidates
673
+ the old key.
545
674
 
546
675
  ## Storage and security
547
676
 
548
677
  - Credentials: `~/.aisubs/credentials.json`.
549
678
  - Persistent local API key: `~/.aisubs/api-key`.
679
+ - Optional Codex catalog: `~/.codex/aisubs-catalog.json`.
680
+ - Codex integration stores the local AISubs key in the user-private Codex config.
550
681
  - State directories and files use private permissions where the platform supports them.
551
682
  - Provider credentials are attached only after provider-host allowlist validation.
552
683
  - Local authorization, cookie, origin, and proxy headers are never forwarded.
@@ -1,6 +1,6 @@
1
1
  export function nextAccountKey(sessions, provider) {
2
2
  const used = new Set(sessions
3
- .filter((session) => session.provider === provider && session.authenticated)
3
+ .filter((session) => session.provider === provider && (session.authenticated || session.reauthRequired))
4
4
  .map((session) => session.accountKey));
5
5
  if (!used.has("default"))
6
6
  return "default";
package/dist/auth.d.ts CHANGED
@@ -66,6 +66,8 @@ export declare class SubscriptionAuth {
66
66
  details(provider: ProviderId, account?: string, signal?: AbortSignal): Promise<SubscriptionAccountDetails>;
67
67
  fetch(provider: ProviderId, input: string | URL | Request, init?: RequestInit, account?: string): Promise<Response>;
68
68
  proxy(provider: ProviderId, account: string, path: string, init?: RequestInit): Promise<Response>;
69
+ /** Build an authorized direct-provider request for transports such as WebSocket. */
70
+ authorizeProxyRequest(provider: ProviderId, account: string, path: string, init?: RequestInit): Promise<Request>;
69
71
  getUsage(provider: ProviderId, account?: string, callerSignal?: AbortSignal): Promise<ProviderUsage | null>;
70
72
  getModels(provider: ProviderId, account?: string, callerSignal?: AbortSignal): Promise<ProviderModels | null>;
71
73
  account(provider: ProviderId, account: string): SubscriptionAccount;
package/dist/auth.js CHANGED
@@ -171,7 +171,7 @@ export class SubscriptionAuth {
171
171
  delete providerOptions.replace;
172
172
  const login = await adapter.startLogin(abort.signal, providerOptions);
173
173
  const id = crypto.randomUUID();
174
- const record = {};
174
+ let record;
175
175
  const promise = login.complete
176
176
  .then(async (credential) => {
177
177
  const saved = await this.store.modify(scope, (current) => {
@@ -194,7 +194,7 @@ export class SubscriptionAuth {
194
194
  throw error;
195
195
  });
196
196
  void promise.catch(() => { });
197
- Object.assign(record, {
197
+ record = {
198
198
  id,
199
199
  provider,
200
200
  accountKey,
@@ -203,7 +203,7 @@ export class SubscriptionAuth {
203
203
  error: null,
204
204
  abort,
205
205
  promise,
206
- });
206
+ };
207
207
  this.attempts.set(id, record);
208
208
  void promise.then(() => this.expireLoginAttempt(id), () => this.expireLoginAttempt(id));
209
209
  return {
@@ -300,7 +300,8 @@ export class SubscriptionAuth {
300
300
  throw new Error(`Not authenticated with ${provider} account ${accountKey}`);
301
301
  if (this.generation(scope) !== epoch)
302
302
  return current;
303
- if (current.accessToken !== observed.accessToken && current.expiresAt > Date.now()) {
303
+ if (current.expiresAt > Date.now() &&
304
+ (current.accessToken !== observed.accessToken || current.expiresAt > observed.expiresAt)) {
304
305
  return current;
305
306
  }
306
307
  const abort = new AbortController();
@@ -421,6 +422,22 @@ export class SubscriptionAuth {
421
422
  const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
422
423
  return this.fetch(provider, new URL(path, base), init, accountKey);
423
424
  }
425
+ /** Build an authorized direct-provider request for transports such as WebSocket. */
426
+ async authorizeProxyRequest(provider, account, path, init) {
427
+ const adapter = this.adapter(provider);
428
+ const accountKey = normalizeAccountKey(account);
429
+ if (adapter.proxy) {
430
+ throw new Error(`${provider} does not expose a direct transport endpoint`);
431
+ }
432
+ const credential = await this.credential(provider, accountKey);
433
+ const baseUrl = typeof adapter.proxyBaseUrl === "function"
434
+ ? adapter.proxyBaseUrl(credential)
435
+ : adapter.proxyBaseUrl;
436
+ if (!baseUrl)
437
+ throw new Error(`${provider} does not expose a direct API endpoint`);
438
+ const base = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
439
+ return adapter.authorize(createRequest(new URL(path, base), init), credential);
440
+ }
424
441
  async getUsage(provider, account = DEFAULT_ACCOUNT, callerSignal) {
425
442
  const adapter = this.adapter(provider);
426
443
  if (!adapter.getUsage)
@@ -0,0 +1,14 @@
1
+ import type { SubscriptionAuth } from "./auth.js";
2
+ import type { ProviderId } from "./types.js";
3
+ export type WireProtocol = "responses" | "chat/completions" | "messages" | "google";
4
+ export declare class CompatibilityError extends Error {
5
+ readonly code: string;
6
+ readonly status: number;
7
+ constructor(message: string, code?: string, status?: number);
8
+ }
9
+ export declare function requestProtocol(path: string): {
10
+ protocol: WireProtocol;
11
+ model?: string;
12
+ stream?: boolean;
13
+ } | null;
14
+ export declare function proxyCompatible(auth: SubscriptionAuth, provider: ProviderId, account: string, path: string, body: Buffer, headers: Headers, signal?: AbortSignal): Promise<Response | null>;