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.
- package/CHANGELOG.md +25 -0
- package/README.md +17 -0
- package/dist/server/db/repositories/codex-accounts.js +15 -3
- package/dist/server/db/repositories/qoder-accounts.js +167 -0
- package/dist/server/db/schema.js +33 -1
- package/dist/server/gateway/runner.js +73 -4
- package/dist/server/providers/codex-refresh.js +5 -2
- package/dist/server/providers/codex.js +53 -19
- package/dist/server/providers/index.js +4 -0
- package/dist/server/providers/pooled.js +84 -0
- package/dist/server/providers/qoder/catalog.js +138 -0
- package/dist/server/providers/qoder/client.js +248 -0
- package/dist/server/providers/qoder/constants.js +34 -0
- package/dist/server/providers/qoder/cosy.js +81 -0
- package/dist/server/providers/qoder/credentials.js +125 -0
- package/dist/server/providers/qoder/encode.js +28 -0
- package/dist/server/providers/qoder/qoder-import.js +109 -0
- package/dist/server/providers/qoder/rsa.js +7 -0
- package/dist/server/providers/qoder/sse.js +211 -0
- package/dist/server/providers/qoder/tier.js +105 -0
- package/dist/server/routes/admin/backup.js +7 -2
- package/dist/server/routes/admin/combos.js +4 -1
- package/dist/server/routes/admin/models.js +14 -0
- package/dist/server/routes/admin/providers.js +53 -34
- package/dist/server/routes/admin/qoder.js +271 -0
- package/dist/server/routes/admin.js +8 -1
- package/dist/server/routing/capabilities.js +1 -0
- package/dist/server/routing/combo.js +22 -3
- package/dist/server/upstream/client.js +14 -0
- package/dist/web/assets/index-DAzlm3Y6.js +391 -0
- package/dist/web/assets/index-Xccks9E5.css +1 -0
- package/dist/web/index.html +2 -2
- package/migrations/0007_qoder_accounts.sql +217 -0
- package/package.json +1 -1
- package/dist/web/assets/index-CkDJqDcS.js +0 -391
- 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 &&
|
|
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
|
|
49
|
-
|
|
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
|
+
}
|
package/dist/server/db/schema.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
85
|
+
const payload = { model: targetModel, input, ...CODEX_REQUIRED_FLAGS };
|
|
72
86
|
if (req.system)
|
|
73
87
|
payload.instructions = req.system;
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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) => {
|