ldrouter 1.16.5 → 1.17.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.
Files changed (36) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README.md +17 -0
  3. package/dist/server/db/repositories/codex-accounts.js +15 -3
  4. package/dist/server/db/repositories/qoder-accounts.js +167 -0
  5. package/dist/server/db/schema.js +33 -1
  6. package/dist/server/gateway/runner.js +73 -4
  7. package/dist/server/providers/codex-refresh.js +5 -2
  8. package/dist/server/providers/codex.js +53 -19
  9. package/dist/server/providers/index.js +4 -0
  10. package/dist/server/providers/pooled.js +84 -0
  11. package/dist/server/providers/qoder/catalog.js +138 -0
  12. package/dist/server/providers/qoder/client.js +248 -0
  13. package/dist/server/providers/qoder/constants.js +34 -0
  14. package/dist/server/providers/qoder/cosy.js +81 -0
  15. package/dist/server/providers/qoder/credentials.js +125 -0
  16. package/dist/server/providers/qoder/encode.js +28 -0
  17. package/dist/server/providers/qoder/qoder-import.js +109 -0
  18. package/dist/server/providers/qoder/rsa.js +7 -0
  19. package/dist/server/providers/qoder/sse.js +211 -0
  20. package/dist/server/providers/qoder/tier.js +105 -0
  21. package/dist/server/routes/admin/backup.js +7 -2
  22. package/dist/server/routes/admin/combos.js +4 -1
  23. package/dist/server/routes/admin/models.js +14 -0
  24. package/dist/server/routes/admin/providers.js +53 -34
  25. package/dist/server/routes/admin/qoder.js +271 -0
  26. package/dist/server/routes/admin.js +8 -1
  27. package/dist/server/routing/capabilities.js +1 -0
  28. package/dist/server/routing/combo.js +22 -3
  29. package/dist/server/upstream/client.js +14 -0
  30. package/dist/web/assets/index-DAzlm3Y6.js +391 -0
  31. package/dist/web/assets/index-Xccks9E5.css +1 -0
  32. package/dist/web/index.html +2 -2
  33. package/migrations/0007_qoder_accounts.sql +217 -0
  34. package/package.json +1 -1
  35. package/dist/web/assets/index-CkDJqDcS.js +0 -391
  36. package/dist/web/assets/index-Drmqi8MO.css +0 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,31 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/) and the project adheres to
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.17.1] - 2026-09-17
8
+
9
+ ### Added
10
+
11
+ - **Combo members are ordered by drag and drop.** The combo create/edit dialog lists members as sortable rows (`@dnd-kit`, the same interaction as the Codex and Qoder account pools): drag the handle — or reorder with the keyboard — and the row order becomes the saved priority, `position` 0 being tried first in fallback mode and leading the rotation in weighted round-robin.
12
+
13
+ ### Fixed
14
+
15
+ - **`GET /api/admin/combos/:id` returned members in SQLite row order instead of priority order**, so a reordered combo could reopen showing the previous sequence. Members are now sorted by `position`.
16
+
17
+ ## [1.17.0] - 2026-09-17
18
+
19
+ ### Added
20
+
21
+ - **Qoder provider.** Qoder joins OpenAI, Anthropic and Codex as a first-class provider type, backed by personal access tokens (`pt-…`) that are exchanged for short-lived job tokens and stored encrypted. It brings a per-account pool with drag-to-reorder routing priority, live model-catalog import, an account health panel, bulk token import, and the signed (COSY) streaming and non-streaming chat client, including context-tier selection and the `Encode=1` body encoding.
22
+
23
+ ### Fixed
24
+
25
+ - **Codex requests never satisfied the backend contract.** Every call answered `502 Codex upstream HTTP 400`: the backend requires `store: false` and `stream: true` verbatim. Non-streaming requests are now served by merging the upstream stream, which also restores tool calls — `response.completed` always carries `output: []`, so they are only observable on `response.output_item.done` and were previously dropped silently on both paths.
26
+ - **Expired Codex tokens could never be refreshed.** Routing and the account lookup rejected an account whose access token had expired, even though credentials are refreshed before use, so a healthy account stopped carrying traffic once its token aged out. The refresh client also omitted `client_id`, making every refresh fail on the backend side. Both are fixed and the account now recovers on the next request.
27
+ - **Codex rejected three optional parameters.** `max_output_tokens`, `temperature` and `top_p` each answered `400 Unsupported parameter`, so any client that set `max_tokens` against a `codex/` model failed — including the Models-page Test button, which always sets it. They are no longer forwarded; `instructions`, `tools` and `reasoning` still are.
28
+ - **Several admin actions failed with `403 CSRF token required`.** The model Test button, database backup create/restore, and the first-run database import called `fetch` directly instead of the shared client, so they carried no CSRF header while every authenticated admin mutation requires one. They now use a CSRF-aware wrapper; the first-run import is the deliberate exception, since no admin session exists yet.
29
+ - **The first-run database import was unreachable.** `POST /api/admin/backup/restore` answered `401 Login required` on a fresh instance, because its exemption from auth ran alongside the other admin modules' hooks on a shared scope, and a `preHandler` cannot cancel a later one. Backup routes now register in their own scope.
30
+ - **The Qoder account enable/disable button appeared dead.** The toggle wrote to the server correctly but never re-read the list, so the row kept showing its previous state. Test and Catalog had the same gap.
31
+
7
32
  ## [1.16.5] - 2026-09-15
8
33
 
9
34
  ### Fixed
package/README.md CHANGED
@@ -20,6 +20,7 @@ Lightweight self-hosted LLM gateway with a polished admin UI. Presents stable Op
20
20
  - Prometheus `/metrics`, structured logs, graceful shutdown
21
21
  - One distributable npm package, multi-stage Dockerfile, Docker Compose
22
22
  - Native Codex OAuth account pools with encrypted JSON/JSONL import, refresh/rotation, account-aware routing, and admin management
23
+ - Native Qoder account pools driven by personal access tokens, with encrypted storage, live model-catalog discovery, and account-aware routing
23
24
 
24
25
  ## Quick start
25
26
 
@@ -64,6 +65,22 @@ ZIP upload and automatic Codex CLI config-file generation/mutation are not inclu
64
65
 
65
66
  The account panel shows the 5-hour and weekly quota windows with reset countdowns, per-account and bulk usage refresh, weekly reset credits, an opt-in 5-hour window auto-start, and a **Test** control that probes the upstream account without exposing tokens. Routing order is set by dragging rows; the saved order is the fallback order the router uses. **Delete** is permanent and erases the stored encrypted credentials — past request logs are kept but lose the account reference.
66
67
 
68
+ ### Qoder setup
69
+
70
+ Qoder providers use a personal access token (PAT) instead of an API key. Create one at `https://qoder.com/account/integrations`; it starts with `pt-`.
71
+
72
+ 1. Set `LATEDEV_MASTER_KEY` before adding a token. It encrypts both the PAT and the derived job token at rest.
73
+ 2. In **Providers**, click **Add Qoder**. The server creates the provider with its fixed endpoint — there is no base URL or API key to fill in.
74
+ 3. In the Qoder account panel, click **Add token** and paste the PAT, or **Import tokens** to paste/upload a list: one token per line, a JSON array of strings, an `{ "accounts": [...] }` wrapper, or JSONL of strings/objects (`token`, `pt_token`, `personal_token`, `access_token`; optional `label`/`name`/`email`). Only `pt-` tokens are accepted — a `dt-` device token or a `jt-` job token is rejected with that reason rather than failing later upstream.
75
+
76
+ Adding a token exchanges it immediately for a short-lived job token and fetches the model list; inference then runs against `api2.qoder.sh` using that job token. The PAT is kept so a new job token can always be minted — the operator never has to touch it again. If the token works but the model list cannot be fetched, the account is still stored (health `unknown`, with the error recorded) so you can retry with **Catalog**.
77
+
78
+ Because the gateway serves model discovery from that live catalog, a new account has no routable models until its first successful fetch. Use **Import models** on the provider row to pull the discovered models in.
79
+
80
+ **Operational caveat:** a revoked or expired PAT is a durable failure. The account is marked `down` with "personal access token rejected — replace it" and routing skips it; nothing retries it forever. Re-add the account with a fresh token. Deleting an account is permanent and erases the stored encrypted PAT — past request logs are kept but lose the account reference.
81
+
82
+ The panel shows the masked user id, job-token expiry, when the catalog was last fetched, health, and an enabled toggle. Routing order is set by dragging rows, exactly as with Codex.
83
+
67
84
  ## Environment variables
68
85
 
69
86
  | Variable | Description | Default |
@@ -41,15 +41,27 @@ export function listCodexAccountsForProvider(providerId) {
41
41
  return client().prepare(`SELECT id, chatgpt_account_id AS chatgptAccountId, enabled, health_state AS healthState, token_expires_at AS tokenExpiresAt, priority FROM codex_accounts WHERE provider_id=? ORDER BY priority,id`).all(providerId);
42
42
  }
43
43
  export function getCodexAccountForProvider(providerId) {
44
- const row = listCodexAccountsForProvider(providerId).find((a) => a.enabled && (a.healthState === 'healthy' || a.healthState === 'unknown') && Date.parse(a.tokenExpiresAt) > Date.now());
44
+ const row = listCodexAccountsForProvider(providerId).find((a) => a.enabled && a.healthState !== 'down');
45
45
  return row?.chatgptAccountId ? { id: row.id, chatgptAccountId: row.chatgptAccountId } : null;
46
46
  }
47
47
  export function getCodexAccountById(id) {
48
- const row = client().prepare(`SELECT id, chatgpt_account_id AS chatgptAccountId, enabled, health_state AS healthState, token_expires_at AS tokenExpiresAt FROM codex_accounts WHERE id=?`).get(id);
49
- if (!row || !row.enabled || !['healthy', 'unknown'].includes(row.healthState) || Date.parse(row.tokenExpiresAt) <= Date.now())
48
+ const row = client().prepare(`SELECT id, chatgpt_account_id AS chatgptAccountId, enabled, health_state AS healthState FROM codex_accounts WHERE id=?`).get(id);
49
+ // No token-expiry predicate: `withCodexCredentials` refreshes a stale token before the upstream
50
+ // call, so rejecting expired accounts here only blocked requests that were about to self-heal.
51
+ if (!row || !row.enabled || row.healthState === 'down')
50
52
  return null;
51
53
  return row.chatgptAccountId ? { id: row.id, chatgptAccountId: row.chatgptAccountId } : null;
52
54
  }
55
+ /**
56
+ * Plain column read — deliberately no eligibility predicate. A pool provider gates account
57
+ * selection itself (`firstEligible`), and a `degraded` or token-expired account is still usable:
58
+ * `withCodexCredentials` refreshes the token before the upstream call. Using `getCodexAccountById`
59
+ * here would reintroduce a second, stricter gate that the route this replaced never had.
60
+ */
61
+ export function chatgptAccountIdOf(id) {
62
+ const row = client().prepare('SELECT chatgpt_account_id AS chatgptAccountId FROM codex_accounts WHERE id=?').get(id);
63
+ return row?.chatgptAccountId ?? null;
64
+ }
53
65
  export function listCodexAccountSummaries(providerId) {
54
66
  const rows = client().prepare(`SELECT id,email,workspace_id AS workspaceId,chatgpt_account_id AS chatgptAccountId,plan_type AS planType,token_expires_at AS tokenExpiresAt,enabled,health_state AS healthState,last_refresh_at AS lastRefreshAt,priority,codex_autostart_enabled AS autostart,codex_usage_json AS usageJson,codex_usage_error AS usageError,codex_usage_updated_at AS usageUpdatedAt,last_pinged_reset_at AS lastPingedResetAt,last_ping_at AS lastPingAt,created_at AS createdAt,updated_at AS updatedAt FROM codex_accounts WHERE provider_id=? ORDER BY priority,id`).all(providerId);
55
67
  return rows.map(toCodexAccountSummaryRow);
@@ -0,0 +1,167 @@
1
+ // Qoder account pool: the upstream credential is a per-account personal access token
2
+ // (`pt-…`) exchanged for a short-lived job token (`jt-…`). Both are encrypted at rest;
3
+ // nothing here ever returns them in a summary.
4
+ import { decryptSecret, encryptSecret } from '../../auth/crypto.js';
5
+ import { uuid } from '../../auth/ids.js';
6
+ import { createHash } from 'node:crypto';
7
+ import { getRawDb } from '../index.js';
8
+ export function maskQoderValue(value) {
9
+ if (!value)
10
+ return null;
11
+ return value.length <= 8 ? `${value.slice(0, 2)}…${value.slice(-2)}` : `${value.slice(0, 4)}…${value.slice(-4)}`;
12
+ }
13
+ export function identityFromQoderRecord(record) {
14
+ return {
15
+ qoderUserId: record.qoderUserId,
16
+ machineId: record.machineId,
17
+ email: record.email,
18
+ label: record.label,
19
+ };
20
+ }
21
+ const SUMMARY_COLUMNS = 'id,provider_id AS providerId,label,email,qoder_user_id AS qoderUserId,machine_id AS machineId,enabled,health_state AS healthState,priority,job_token_expires_at AS jobTokenExpiresAt,catalog_fetched_at AS catalogFetchedAt,last_error AS lastError,consecutive_failures AS consecutiveFailures,created_at AS createdAt,updated_at AS updatedAt';
22
+ export function toQoderAccountSummary(row) {
23
+ return {
24
+ id: row.id,
25
+ label: row.label,
26
+ email: row.email,
27
+ qoderUserIdMasked: maskQoderValue(row.qoderUserId),
28
+ // SQLite hands raw rows back as 0/1; the API contract (and the web UI) expects a boolean.
29
+ enabled: Boolean(row.enabled),
30
+ healthState: row.healthState,
31
+ priority: row.priority,
32
+ jobTokenExpiresAt: row.jobTokenExpiresAt,
33
+ catalogFetchedAt: row.catalogFetchedAt,
34
+ lastError: row.lastError,
35
+ consecutiveFailures: row.consecutiveFailures,
36
+ createdAt: row.createdAt,
37
+ updatedAt: row.updatedAt,
38
+ };
39
+ }
40
+ export function listQoderAccountSummaries(providerId) {
41
+ const rows = getRawDb()
42
+ .prepare(`SELECT ${SUMMARY_COLUMNS} FROM qoder_accounts WHERE provider_id=? ORDER BY priority,id`)
43
+ .all(providerId);
44
+ return rows.map(toQoderAccountSummary);
45
+ }
46
+ export function listQoderAccountsForProvider(providerId) {
47
+ const rows = getRawDb()
48
+ .prepare(`SELECT ${SUMMARY_COLUMNS} FROM qoder_accounts WHERE provider_id=? ORDER BY priority,id`)
49
+ .all(providerId);
50
+ return rows.map((row) => ({ ...toQoderAccountSummary(row), providerId: row.providerId, qoderUserId: row.qoderUserId, machineId: row.machineId }));
51
+ }
52
+ export function findEligibleQoderAccount(providerId) {
53
+ const row = getRawDb()
54
+ .prepare(`SELECT id,qoder_user_id AS qoderUserId,enabled,health_state AS healthState,priority
55
+ FROM qoder_accounts WHERE provider_id=? AND enabled=1 AND health_state<>'down'
56
+ ORDER BY priority,id LIMIT 1`)
57
+ .get(providerId);
58
+ if (!row)
59
+ return null;
60
+ return { id: row.id, qoderUserId: row.qoderUserId, enabled: Boolean(row.enabled), healthState: row.healthState, priority: row.priority };
61
+ }
62
+ /**
63
+ * Match an imported PAT against existing rows. The PAT is the identity: the same token re-imported
64
+ * must update its account rather than create a duplicate. Rows are compared by decrypting each
65
+ * stored PAT (a handful at most) because the plaintext digest cannot be stored — that would make
66
+ * the token recoverable from the database.
67
+ */
68
+ export function findQoderAccountForImport(providerId, personalToken) {
69
+ const target = createHash('sha256').update(personalToken, 'utf8').digest('hex');
70
+ const rows = getRawDb().prepare('SELECT id FROM qoder_accounts WHERE provider_id=?').all(providerId);
71
+ for (const row of rows) {
72
+ const digest = createHash('sha256').update(getQoderCredentials(row.id).personalToken, 'utf8').digest('hex');
73
+ if (digest === target)
74
+ return row.id;
75
+ }
76
+ return null;
77
+ }
78
+ export function getQoderCredentials(id) {
79
+ const row = getRawDb()
80
+ .prepare('SELECT encrypted_pat, pat_nonce, pat_version, encrypted_job_token, job_token_nonce, job_token_version, job_token_expires_at AS jobTokenExpiresAt FROM qoder_accounts WHERE id=?')
81
+ .get(id);
82
+ if (!row)
83
+ throw new Error('Qoder account not found');
84
+ return {
85
+ personalToken: decryptSecret({ ciphertext: row.encrypted_pat, nonce: row.pat_nonce, version: row.pat_version }),
86
+ jobToken: decryptSecret({ ciphertext: row.encrypted_job_token, nonce: row.job_token_nonce, version: row.job_token_version }),
87
+ jobTokenExpiresAt: row.jobTokenExpiresAt,
88
+ };
89
+ }
90
+ export function getQoderAccountRefreshState(id) {
91
+ const row = getRawDb().prepare('SELECT job_token_expires_at AS jobTokenExpiresAt FROM qoder_accounts WHERE id=?').get(id);
92
+ return row ?? null;
93
+ }
94
+ /** Identity of one account by its own id (the pool-wide lookups are keyed by provider). */
95
+ export function getQoderAccountDetailById(id) {
96
+ const row = getRawDb().prepare(`SELECT ${SUMMARY_COLUMNS} FROM qoder_accounts WHERE id=?`).get(id);
97
+ return row ? { ...toQoderAccountSummary(row), providerId: row.providerId, qoderUserId: row.qoderUserId, machineId: row.machineId } : null;
98
+ }
99
+ function persistQoderAccount(providerId, record, existingId) {
100
+ const raw = getRawDb();
101
+ const pat = encryptSecret(record.personalToken);
102
+ const jobToken = encryptSecret(record.jobToken);
103
+ const now = existingId ? nextUpdatedAt(existingId) : new Date().toISOString();
104
+ const transaction = raw.transaction(() => {
105
+ if (existingId) {
106
+ raw
107
+ .prepare(`UPDATE qoder_accounts SET email=?, label=?, machine_id=?, encrypted_pat=?, pat_nonce=?, pat_version=?,
108
+ encrypted_job_token=?, job_token_nonce=?, job_token_version=?, job_token_expires_at=?,
109
+ last_error=NULL, consecutive_failures=0, health_state='unknown', updated_at=? WHERE id=?`)
110
+ .run(record.email, record.label, record.machineId, pat.ciphertext, pat.nonce, pat.version, jobToken.ciphertext, jobToken.nonce, jobToken.version, record.jobTokenExpiresAt, now, existingId);
111
+ return existingId;
112
+ }
113
+ const priority = raw.prepare('SELECT COALESCE(MAX(priority), -1) AS value FROM qoder_accounts WHERE provider_id=?').get(providerId).value + 1;
114
+ const id = uuid();
115
+ raw
116
+ .prepare(`INSERT INTO qoder_accounts (id, provider_id, email, label, qoder_user_id, machine_id, encrypted_pat, pat_nonce, pat_version,
117
+ encrypted_job_token, job_token_nonce, job_token_version, job_token_expires_at, priority)
118
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
119
+ .run(id, providerId, record.email, record.label, record.qoderUserId, record.machineId, pat.ciphertext, pat.nonce, pat.version, jobToken.ciphertext, jobToken.nonce, jobToken.version, record.jobTokenExpiresAt, priority);
120
+ return id;
121
+ });
122
+ return transaction();
123
+ }
124
+ export function insertQoderAccount(providerId, record) {
125
+ return persistQoderAccount(providerId, record);
126
+ }
127
+ export function upsertQoderAccount(providerId, record) {
128
+ const existing = getRawDb()
129
+ .prepare('SELECT id FROM qoder_accounts WHERE provider_id=? AND qoder_user_id=?')
130
+ .get(providerId, record.qoderUserId);
131
+ if (!existing)
132
+ return { id: persistQoderAccount(providerId, record), status: 'inserted' };
133
+ return { id: persistQoderAccount(providerId, record, existing.id), status: 'updated' };
134
+ }
135
+ export function persistQoderJobToken(id, update) {
136
+ const jobToken = encryptSecret(update.jobToken);
137
+ getRawDb()
138
+ .prepare(`UPDATE qoder_accounts SET encrypted_job_token=?, job_token_nonce=?, job_token_version=?, job_token_expires_at=?,
139
+ catalog_json=COALESCE(?, catalog_json), catalog_fetched_at=COALESCE(?, catalog_fetched_at),
140
+ health_state='healthy', last_error=NULL, updated_at=? WHERE id=?`)
141
+ .run(jobToken.ciphertext, jobToken.nonce, jobToken.version, update.expiresAt, update.catalogJson ?? null, update.catalogFetchedAt ?? null, nextUpdatedAt(id), id);
142
+ }
143
+ export function setQoderAccountHealth(id, healthState, lastError = null) {
144
+ const failures = healthState === 'healthy' ? 0 : null;
145
+ getRawDb()
146
+ .prepare(`UPDATE qoder_accounts SET health_state=?, last_error=?, consecutive_failures=COALESCE(?, consecutive_failures), updated_at=? WHERE id=?`)
147
+ .run(healthState, lastError, failures, nextUpdatedAt(id), id);
148
+ }
149
+ export function readQoderCatalog(id) {
150
+ const row = getRawDb()
151
+ .prepare('SELECT catalog_json AS catalogJson, catalog_fetched_at AS catalogFetchedAt FROM qoder_accounts WHERE id=?')
152
+ .get(id);
153
+ return row ?? null;
154
+ }
155
+ export function saveQoderCatalog(id, catalogJson, fetchedAt) {
156
+ getRawDb()
157
+ .prepare('UPDATE qoder_accounts SET catalog_json=?, catalog_fetched_at=?, updated_at=? WHERE id=?')
158
+ .run(catalogJson, fetchedAt ?? new Date().toISOString(), nextUpdatedAt(id), id);
159
+ }
160
+ // Keeps `updated_at` strictly increasing even within the same millisecond, so a caller can
161
+ // detect a write it just made. Copied from the Codex repository for consistency.
162
+ function nextUpdatedAt(id) {
163
+ const current = getRawDb().prepare('SELECT updated_at AS updatedAt FROM qoder_accounts WHERE id=?').get(id);
164
+ const now = Date.now();
165
+ const previous = current ? Date.parse(current.updatedAt) : 0;
166
+ return new Date(Math.max(now, previous + 1)).toISOString();
167
+ }
@@ -81,7 +81,7 @@ export const providers = sqliteTable('providers', {
81
81
  id: text('id').primaryKey(),
82
82
  name: text('name').notNull(),
83
83
  slug: text('slug').notNull().unique(),
84
- type: text('type', { enum: ['openai', 'anthropic', 'codex'] }).notNull(),
84
+ type: text('type', { enum: ['openai', 'anthropic', 'codex', 'qoder'] }).notNull(),
85
85
  baseUrl: text('base_url').notNull(),
86
86
  encryptedApiKey: text('encrypted_api_key'),
87
87
  apiKeyNonce: text('api_key_nonce'),
@@ -146,6 +146,36 @@ export const codexAccounts = sqliteTable('codex_accounts', {
146
146
  providerChatgptIdx: index('idx_codex_account_provider_chatgpt').on(t.providerId, t.chatgptAccountId),
147
147
  }));
148
148
  // ============================================================================
149
+ // Qoder accounts
150
+ // ============================================================================
151
+ export const qoderAccounts = sqliteTable('qoder_accounts', {
152
+ id: text('id').primaryKey(),
153
+ providerId: text('provider_id').notNull().references(() => providers.id, { onDelete: 'restrict' }),
154
+ label: text('label'),
155
+ email: text('email'),
156
+ qoderUserId: text('qoder_user_id').notNull(),
157
+ machineId: text('machine_id').notNull(),
158
+ encryptedPat: text('encrypted_pat').notNull(),
159
+ patNonce: text('pat_nonce').notNull(),
160
+ patVersion: integer('pat_version').notNull().default(1),
161
+ encryptedJobToken: text('encrypted_job_token').notNull(),
162
+ jobTokenNonce: text('job_token_nonce').notNull(),
163
+ jobTokenVersion: integer('job_token_version').notNull().default(1),
164
+ jobTokenExpiresAt: text('job_token_expires_at').notNull(),
165
+ catalogJson: text('catalog_json'),
166
+ catalogFetchedAt: text('catalog_fetched_at'),
167
+ enabled: integer('enabled', { mode: 'boolean' }).notNull().default(true),
168
+ healthState: text('health_state', { enum: ['healthy', 'degraded', 'down', 'unknown'] }).notNull().default('unknown'),
169
+ lastError: text('last_error'),
170
+ consecutiveFailures: integer('consecutive_failures').notNull().default(0),
171
+ priority: integer('priority').notNull().default(0),
172
+ createdAt: text('created_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
173
+ updatedAt: text('updated_at').notNull().default(sql `(strftime('%Y-%m-%dT%H:%M:%fZ','now'))`),
174
+ }, (t) => ({
175
+ providerEnabledPriorityIdx: index('idx_qoder_account_provider_enabled_priority').on(t.providerId, t.enabled, t.priority),
176
+ providerUserIdx: index('idx_qoder_account_provider_user').on(t.providerId, t.qoderUserId),
177
+ }));
178
+ // ============================================================================
149
179
  // Models
150
180
  // ============================================================================
151
181
  export const models = sqliteTable('models', {
@@ -327,6 +357,7 @@ export const requestAttempts = sqliteTable('request_attempts', {
327
357
  providerId: text('provider_id').notNull(),
328
358
  modelId: text('model_id').notNull(),
329
359
  codexAccountId: text('codex_account_id').references(() => codexAccounts.id, { onDelete: 'set null' }),
360
+ qoderAccountId: text('qoder_account_id').references(() => qoderAccounts.id, { onDelete: 'set null' }),
330
361
  startedAt: text('started_at').notNull(),
331
362
  completedAt: text('completed_at'),
332
363
  statusCode: integer('status_code'),
@@ -348,6 +379,7 @@ export const requestAttempts = sqliteTable('request_attempts', {
348
379
  requestIdx: index('idx_attempt_request').on(t.requestId, t.attemptNumber),
349
380
  providerModelIdx: index('idx_attempt_provider_model').on(t.providerId, t.modelId, t.startedAt),
350
381
  codexAccountIdx: index('idx_attempt_codex_account').on(t.codexAccountId, t.startedAt),
382
+ qoderAccountIdx: index('idx_attempt_qoder_account').on(t.qoderAccountId, t.startedAt),
351
383
  }));
352
384
  // ============================================================================
353
385
  // Audit logs (immutable, excluded from request retention)
@@ -4,8 +4,11 @@ import { eq } from 'drizzle-orm';
4
4
  import { GatewayError } from '../errors.js';
5
5
  import { resolveRequestedModel, unwrapAlias } from '../routing/resolver.js';
6
6
  import { bareModelName, deriveRequiredCapabilities, describeRejections, firstMissingCapability } from '../routing/capabilities.js';
7
- import { loadCombo, selectCandidates, orderCandidates, shouldFallback, expandCodexAccountCandidates } from '../routing/combo.js';
7
+ import { loadCombo, selectCandidates, orderCandidates, shouldFallback, expandCodexAccountCandidates, expandQoderAccountCandidates } from '../routing/combo.js';
8
8
  import { listCodexAccountsForProvider, setCodexAccountHealth } from '../db/repositories/codex-accounts.js';
9
+ import { listQoderAccountsForProvider, setQoderAccountHealth } from '../db/repositories/qoder-accounts.js';
10
+ import { qoderAttemptFailure, qoderCredentialsFor } from '../providers/qoder/credentials.js';
11
+ import { callQoderNonStreaming, callQoderStreaming } from '../providers/qoder/client.js';
9
12
  import { getEffectiveState, isOpen, recordSuccess, recordFailure, halfOpenProbeAllowed } from '../routing/circuit.js';
10
13
  import { checkRpm, checkTpm, acquireConcurrent, releaseConcurrent } from '../routing/ratelimit.js';
11
14
  import { checkDailyMonthly, consumeUsage } from '../routing/quota.js';
@@ -133,7 +136,11 @@ export class GatewayRunner {
133
136
  }
134
137
  candidates = orderCandidates(comboPlan, filtered).flatMap((candidate) => {
135
138
  const provider = getDb().select().from(schema.providers).where(eq(schema.providers.id, candidate.providerId)).get();
136
- return provider?.type === 'codex' ? expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(provider.id)) : [candidate];
139
+ if (provider?.type === 'codex')
140
+ return expandCodexAccountCandidates(candidate, listCodexAccountsForProvider(provider.id));
141
+ if (provider?.type === 'qoder')
142
+ return expandQoderAccountCandidates(candidate, listQoderAccountsForProvider(provider.id));
143
+ return [candidate];
137
144
  });
138
145
  debugHttp(ctx.requestId, 'CANDIDATES ORDERED', [
139
146
  `mode=${comboPlan.mode}`,
@@ -235,6 +242,7 @@ export class GatewayRunner {
235
242
  partialResponse: false,
236
243
  selectionReason: candidate.selectionReason ?? selectionReasons[0] ?? 'direct',
237
244
  codexAccountId: candidate.codexAccountId,
245
+ qoderAccountId: candidate.qoderAccountId,
238
246
  failureReason: null,
239
247
  sanitizedError: null,
240
248
  upstreamRequestId: null,
@@ -269,6 +277,8 @@ export class GatewayRunner {
269
277
  recordSuccess(provider.id);
270
278
  if (candidate.codexAccountId)
271
279
  setCodexAccountHealth(candidate.codexAccountId, 'healthy');
280
+ if (candidate.qoderAccountId)
281
+ setQoderAccountHealth(candidate.qoderAccountId, 'healthy');
272
282
  getDb().update(schema.providers).set({ healthState: 'healthy', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
273
283
  attempts.push(attempt);
274
284
  sentToClient = out.streamStarted ?? false;
@@ -306,6 +316,8 @@ export class GatewayRunner {
306
316
  recordFailure(provider.id, provider.cbFailureThreshold, provider.cbCooldownSeconds);
307
317
  if (candidate.codexAccountId)
308
318
  setCodexAccountHealth(candidate.codexAccountId, 'down', redactString(err.message));
319
+ if (candidate.qoderAccountId)
320
+ setQoderAccountHealth(candidate.qoderAccountId, 'down', redactString(err.message));
309
321
  getDb().update(schema.providers).set({ healthState: 'down', updatedAt: new Date().toISOString() }).where(eq(schema.providers.id, provider.id)).run();
310
322
  }
311
323
  if (shouldRetry && i + 1 < maxAttempts) {
@@ -398,6 +410,7 @@ export class GatewayRunner {
398
410
  upstreamAvailable: m.upstreamAvailable,
399
411
  circuitOpen: isOpen(m.providerId),
400
412
  capabilities: caps,
413
+ providerType: p.type,
401
414
  };
402
415
  if (!m.enabled)
403
416
  return { candidates: [], rejected: reject(m.publicModelId, 'model_disabled') };
@@ -424,6 +437,12 @@ export class GatewayRunner {
424
437
  return { candidates: [], rejected: reject(m.publicModelId, 'codex_account_unavailable') };
425
438
  return { candidates: expanded, rejected: [] };
426
439
  }
440
+ if (p.type === 'qoder') {
441
+ const expanded = expandQoderAccountCandidates(candidate, listQoderAccountsForProvider(p.id));
442
+ if (expanded.length === 0)
443
+ return { candidates: [], rejected: reject(m.publicModelId, 'qoder_account_unavailable') };
444
+ return { candidates: expanded, rejected: [] };
445
+ }
427
446
  return { candidates: [candidate], rejected: [] };
428
447
  }
429
448
  async loadAllModels() {
@@ -431,6 +450,7 @@ export class GatewayRunner {
431
450
  const models = db.select().from(schema.models).all();
432
451
  const providers = db.select().from(schema.providers).all();
433
452
  const providerEnabled = new Map(providers.map((p) => [p.id, p.enabled]));
453
+ const providerType = new Map(providers.map((p) => [p.id, p.type]));
434
454
  // Deliberately unfiltered: every combo member must reach selectCandidates so
435
455
  // it can report WHY it was skipped. Pre-filtering here erased the model rows
436
456
  // and turned every distinct reason into "model not found".
@@ -443,6 +463,7 @@ export class GatewayRunner {
443
463
  upstreamAvailable: m.upstreamAvailable,
444
464
  circuitOpen: isOpen(m.providerId),
445
465
  capabilities: safeJson(m.capabilitiesJson),
466
+ providerType: providerType.get(m.providerId),
446
467
  }));
447
468
  }
448
469
  async runOneAttempt(req, ctx, candidate, providerName, cfg, required, onStreamStart, onFirstToken) {
@@ -457,6 +478,19 @@ export class GatewayRunner {
457
478
  const out = await callCodexNonStreaming({ baseUrl: cfg.baseUrl, accountId: cfg.codexAccountId ?? '', accountRecordId: cfg.accountRecordId, customHeaders: cfg.customHeaders, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel });
458
479
  return { statusCode: out.status, ttftMs: null, upstreamRequestId: out.upstreamRequestId, usage: out.usage, result: { text: out.text, toolCalls: out.toolCalls, finishReason: out.finishReason } };
459
480
  }
481
+ if (cfg.type === 'qoder') {
482
+ const accountRecordId = candidate.qoderAccountId ?? cfg.qoderAccountRecordId;
483
+ if (!accountRecordId)
484
+ throw new GatewayError('upstream_unavailable', 'No Qoder account is available for this request', { status: 503, code: 'qoder_account_unavailable' });
485
+ try {
486
+ const { config } = await qoderCredentialsFor(accountRecordId);
487
+ const out = await callQoderNonStreaming({ ...config, accountRecordId, name: config.label ?? undefined, email: config.email, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel });
488
+ return { statusCode: out.status, ttftMs: null, upstreamRequestId: out.upstreamRequestId, usage: out.usage, result: { text: out.text, toolCalls: out.toolCalls, finishReason: out.finishReason } };
489
+ }
490
+ catch (error) {
491
+ return qoderAttemptFailure(accountRecordId, error);
492
+ }
493
+ }
460
494
  let call;
461
495
  if (cfg.type === 'openai') {
462
496
  const payload = canonicalToOpenAIRequest(req.canonical, upstreamModel);
@@ -630,7 +664,7 @@ export class GatewayRunner {
630
664
  const events = [];
631
665
  let firstCodexEvent = true;
632
666
  const meta = await callCodexStreaming({ baseUrl: cfg.baseUrl, accountId: cfg.codexAccountId ?? '', accountRecordId: cfg.accountRecordId, customHeaders: cfg.customHeaders, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel }, (event) => {
633
- if (event.text || event.isLast) {
667
+ if (event.text || event.isLast || event.toolCall) {
634
668
  events.push(event);
635
669
  if (event.usage)
636
670
  Object.assign(usage, event.usage);
@@ -646,6 +680,35 @@ export class GatewayRunner {
646
680
  usage.total = usage.input + usage.output;
647
681
  return { statusCode: meta.status, ttftMs: events.length ? Date.now() - streamStartTs : null, upstreamRequestId: meta.upstreamRequestId, usage, result: { text: textBuf, toolCalls: toolBuf, finishReason } };
648
682
  }
683
+ if (cfg.type === 'qoder') {
684
+ const accountRecordId = candidate.qoderAccountId ?? cfg.qoderAccountRecordId;
685
+ if (!accountRecordId)
686
+ throw new GatewayError('upstream_unavailable', 'No Qoder account is available for this request', { status: 503, code: 'qoder_account_unavailable' });
687
+ let firstQoderChunk = true;
688
+ let qoderTtftMs = null;
689
+ try {
690
+ const { config } = await qoderCredentialsFor(accountRecordId);
691
+ const out = await callQoderStreaming({ ...config, accountRecordId, name: config.label ?? undefined, email: config.email, totalTimeoutMs: cfg.totalTimeoutMs }, { ...req.canonical, model: upstreamModel },
692
+ // Qoder already emits OpenAI-shaped chunks, so they pipe through unchanged.
693
+ (chunk) => {
694
+ if (firstQoderChunk) {
695
+ qoderTtftMs = Date.now() - streamStartTs;
696
+ firstQoderChunk = false;
697
+ }
698
+ chunkHandler({ data: chunk.data }, false);
699
+ });
700
+ if (!headWritten)
701
+ writeHead();
702
+ pipe.write('data: [DONE]\n\n');
703
+ pipe.end();
704
+ if (!usage.total)
705
+ usage.total = usage.input + usage.output;
706
+ return { statusCode: out.status, ttftMs: qoderTtftMs, upstreamRequestId: out.upstreamRequestId, usage, result: { text: out.text, toolCalls: out.toolCalls, finishReason: out.finishReason } };
707
+ }
708
+ catch (error) {
709
+ return qoderAttemptFailure(accountRecordId, error);
710
+ }
711
+ }
649
712
  const url = cfg.type === 'openai' ? upstreamUrl(cfg, '/v1/chat/completions') : upstreamUrl(cfg, '/v1/messages');
650
713
  const payload = cfg.type === 'openai' ? canonicalToOpenAIRequest(req.canonical, upstreamModel) : canonicalToAnthropicRequest(req.canonical, upstreamModel);
651
714
  logUpstreamRequest(ctx.requestId, cfg, url, payload, true);
@@ -882,6 +945,7 @@ export class GatewayRunner {
882
945
  providerId: a.providerId,
883
946
  modelId: a.modelId,
884
947
  codexAccountId: a.codexAccountId ?? null,
948
+ qoderAccountId: a.qoderAccountId ?? null,
885
949
  startedAt: a.startedAt,
886
950
  completedAt: a.completedAt,
887
951
  statusCode: a.statusCode,
@@ -1017,14 +1081,19 @@ function usageFromCache(u) {
1017
1081
  // Map Codex's native response events to the public protocol stream shape.
1018
1082
  export function codexStreamEventToClient(protocol, event, model, requestId) {
1019
1083
  if (protocol === 'openai') {
1084
+ const delta = event.toolCall
1085
+ ? { tool_calls: [{ index: 0, id: event.toolCall.id, type: 'function', function: { name: event.toolCall.name, arguments: JSON.stringify(event.toolCall.input) } }] }
1086
+ : event.isLast ? {} : { content: event.text };
1020
1087
  return JSON.stringify({
1021
1088
  id: `chatcmpl-${requestId}`,
1022
1089
  object: 'chat.completion.chunk',
1023
1090
  created: Math.floor(Date.now() / 1000),
1024
1091
  model,
1025
- choices: [{ index: 0, delta: event.isLast ? {} : { content: event.text }, finish_reason: event.isLast ? 'stop' : null }],
1092
+ choices: [{ index: 0, delta, finish_reason: event.isLast ? 'stop' : null }],
1026
1093
  });
1027
1094
  }
1095
+ if (event.toolCall)
1096
+ return JSON.stringify({ type: 'content_block_start', index: 0, content_block: { type: 'tool_use', id: event.toolCall.id, name: event.toolCall.name, input: event.toolCall.input } });
1028
1097
  return JSON.stringify(event.isLast
1029
1098
  ? { type: 'message_delta', delta: { stop_reason: 'end_turn', stop_sequence: null }, usage: { output_tokens: 0 } }
1030
1099
  : { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: event.text } });
@@ -1,4 +1,5 @@
1
1
  import { getCodexCredentials, getCodexAccountRefreshState, persistCodexRefresh, setCodexAccountHealth } from '../db/repositories/codex-accounts.js';
2
+ import { CODEX_OAUTH } from './codex-oauth.js';
2
3
  import { GatewayError } from '../errors.js';
3
4
  /**
4
5
  * Maps the credential-layer's opaque error codes onto typed admin-facing errors. Call sites that
@@ -28,10 +29,12 @@ export function needsCodexRefresh(account, now = new Date()) {
28
29
  return !Number.isFinite(expires) || expires - now.getTime() <= REFRESH_LEAD_MS;
29
30
  }
30
31
  async function defaultRefreshClient(input) {
31
- const response = await fetch('https://auth.openai.com/oauth/token', {
32
+ const response = await fetch(CODEX_OAUTH.tokenUrl, {
32
33
  method: 'POST',
33
34
  headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
34
- body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: input.refreshToken }),
35
+ // `client_id` is required: without it the endpoint answers 400 `Missing 'client_id'`,
36
+ // which surfaced as an opaque refresh failure and left expired tokens unusable.
37
+ body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: input.refreshToken, client_id: CODEX_OAUTH.clientId }),
35
38
  signal: input.signal,
36
39
  });
37
40
  if (!response.ok)
@@ -5,8 +5,16 @@ const base = (url) => url.replace(/\/$/, '');
5
5
  * `400 [{'loc': ('query', 'client_version'), 'msg': 'Field required'}]`.
6
6
  */
7
7
  export const CODEX_CLIENT_VERSION = '0.144.6';
8
+ /**
9
+ * The Codex OAuth backend only exists on chatgpt.com — `api.openai.com/backend-api/codex/*`
10
+ * answers 404. The base URL is therefore not user-configurable: a stored value pointing
11
+ * anywhere else makes discovery and routing fail with an opaque 404.
12
+ */
13
+ export const CODEX_BASE_URL = 'https://chatgpt.com';
8
14
  export function codexRequest(cfg, path) {
9
- return `${base(cfg.baseUrl)}/backend-api/codex${path.startsWith('/') ? path : `/${path}`}`;
15
+ // Always chatgpt.com: the Codex OAuth backend does not exist on api.openai.com,
16
+ // so a stored base URL pointing elsewhere yields an opaque 404. See CODEX_BASE_URL.
17
+ return `${base(CODEX_BASE_URL)}/backend-api/codex${path.startsWith('/') ? path : `/${path}`}`;
10
18
  }
11
19
  export function codexHeaders(cfg, accept = 'application/json') {
12
20
  if (!cfg.accessToken)
@@ -60,6 +68,12 @@ export async function codexModels(cfg) {
60
68
  ctl.cancel();
61
69
  }
62
70
  }
71
+ /**
72
+ * The Codex backend rejects any request that does not set both flags verbatim:
73
+ * `{"detail":"Store must be set to false"}` / `{"detail":"Stream must be set to true"}`.
74
+ * Upstream always streams; non-streaming callers merge the stream below.
75
+ */
76
+ const CODEX_REQUIRED_FLAGS = { store: false, stream: true };
63
77
  export function codexRequestPayload(req, targetModel = req.model) {
64
78
  const input = req.messages.map((message) => ({ role: message.role, content: message.content.map((block) => {
65
79
  if (block.type === 'text')
@@ -68,11 +82,12 @@ export function codexRequestPayload(req, targetModel = req.model) {
68
82
  return { type: 'input_image', image_url: block.image?.url ?? `data:${block.image?.mimeType ?? 'image/png'};base64,${block.image?.base64}` };
69
83
  return null;
70
84
  }).filter(Boolean) }));
71
- const payload = { model: targetModel, input, stream: req.stream };
85
+ const payload = { model: targetModel, input, ...CODEX_REQUIRED_FLAGS };
72
86
  if (req.system)
73
87
  payload.instructions = req.system;
74
- if (req.maxOutputTokens !== undefined)
75
- payload.max_output_tokens = req.maxOutputTokens;
88
+ // max_output_tokens, temperature and top_p are NOT forwarded: the Codex backend rejects each
89
+ // with 400 "Unsupported parameter" (verified live against chatgpt.com). The Codex CLI omits them
90
+ // too, so a client that sets max_tokens on a codex/ model still gets a working request.
76
91
  if (req.tools?.length)
77
92
  payload.tools = req.tools.map((tool) => ({ type: 'function', name: tool.name, description: tool.description, parameters: tool.inputSchema }));
78
93
  if (req.reasoning?.effort)
@@ -106,6 +121,24 @@ export function codexResponseToCanonical(body, requestedModel) {
106
121
  export function codexStreamEventToCanonical(event) {
107
122
  if (event.type === 'response.output_text.delta')
108
123
  return { text: typeof event.delta === 'string' ? event.delta : '', isLast: false };
124
+ if (event.type === 'response.output_item.done') {
125
+ // `response.completed` always carries `output: []`, so function calls are only
126
+ // observable here — this is what lets a non-streaming merge see tool calls.
127
+ const item = event.item && typeof event.item === 'object' ? event.item : {};
128
+ if (item.type !== 'function_call')
129
+ return { text: '', isLast: false };
130
+ const raw = item.arguments;
131
+ let input = raw ?? {};
132
+ if (typeof raw === 'string') {
133
+ try {
134
+ input = JSON.parse(raw);
135
+ }
136
+ catch {
137
+ input = raw;
138
+ }
139
+ }
140
+ return { text: '', isLast: false, toolCall: { id: String(item.call_id ?? item.id ?? ''), name: String(item.name ?? ''), input } };
141
+ }
109
142
  if (event.type === 'response.completed') {
110
143
  const response = event.response && typeof event.response === 'object' ? event.response : {};
111
144
  return { text: '', isLast: true, usage: usage(response.usage), finishReason: typeof response.status === 'string' ? response.status : null };
@@ -117,21 +150,22 @@ async function responseError(response) {
117
150
  return error;
118
151
  }
119
152
  export async function callCodexNonStreaming(cfg, req) {
120
- const call = async (tokenCfg) => {
121
- const ctl = timeout(tokenCfg.totalTimeoutMs);
122
- try {
123
- const response = await fetch(codexRequest(tokenCfg, '/responses'), { method: 'POST', headers: codexHeaders(tokenCfg), body: JSON.stringify(codexRequestPayload(req)), signal: ctl.signal });
124
- if (!response.ok)
125
- throw await responseError(response);
126
- return { ...codexResponseToCanonical(await response.json(), req.model), status: response.status, upstreamRequestId: response.headers.get('x-request-id') };
127
- }
128
- finally {
129
- ctl.cancel();
130
- }
131
- };
132
- if (cfg.accountRecordId)
133
- return withCodexCredentials(cfg.accountRecordId, (credentials) => call({ ...cfg, accessToken: credentials.accessToken }));
134
- return call(cfg);
153
+ // The Codex backend rejects `stream:false`, so a non-streaming caller merges the
154
+ // stream itself instead of asking upstream for a single JSON body.
155
+ let text = '';
156
+ const toolCalls = [];
157
+ let finishReason = null;
158
+ let usage = { input: 0, output: 0, total: 0, cacheRead: 0, cacheWrite: 0, reasoning: 0 };
159
+ const meta = await callCodexStreaming(cfg, req, (event) => {
160
+ text += event.text;
161
+ if (event.toolCall)
162
+ toolCalls.push(event.toolCall);
163
+ if (event.usage)
164
+ usage = event.usage;
165
+ if (event.isLast && event.finishReason !== undefined)
166
+ finishReason = event.finishReason;
167
+ });
168
+ return { model: req.model, text, toolCalls, finishReason, usage, status: meta.status, upstreamRequestId: meta.upstreamRequestId };
135
169
  }
136
170
  export async function callCodexStreaming(cfg, req, onEvent) {
137
171
  const run = async (tokenCfg) => {