-
Notifications
You must be signed in to change notification settings - Fork 941
feat(provider): add Qoder CN OAuth provider and streaming adapter #3010
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
34ea3b5
507ca59
56202b5
328ae30
a3012ca
63e3449
c66a6a2
f6c9b33
898e4ad
a98626b
94337bb
1206d83
8b0452a
71b81c8
29a65cf
b72f32f
291164b
2e35823
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,179 @@ | ||
| /** | ||
| * Qoder CN OAuth flow (device authorization grant with PKCE). | ||
| */ | ||
| import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { randomUUID } from "node:crypto"; | ||
| import { getConfigDir } from "../config"; | ||
| import { recordOwnedConfigPath } from "../lib/config-ownership"; | ||
| import { generatePKCE } from "./pkce"; | ||
| import type { OAuthController, OAuthCredentials } from "./types"; | ||
|
|
||
| const CLIENT_ID = "e883ade2-e6e3-4d6d-adf7-f92ceff5fdcb"; | ||
| const DEFAULT_OPENAPI_HOST = "https://openapi.qoder.com.cn"; | ||
| const DEFAULT_AUTH_HOST = "https://qoder.cn"; | ||
| const MACHINE_ID_FILENAME = "qodercn-machine-id"; | ||
| const POLL_INTERVAL_MS = 1500; | ||
| const POLL_TIMEOUT_MS = 5 * 60 * 1000; | ||
| const OAUTH_EXPIRY_SKEW_MS = 5 * 60 * 1000; | ||
|
|
||
| interface QoderDevicePollResponse { | ||
| token?: string; | ||
| device_token?: string; | ||
| refresh_token?: string; | ||
| expires_at?: string; | ||
| expires_in?: number; | ||
| refresh_token_expires_at?: string; | ||
| refresh_token_expires_in?: number; | ||
| user_id?: string; | ||
| user_name?: string; | ||
| email?: string; | ||
| } | ||
|
|
||
| interface QoderTokenRefreshResponse { | ||
| device_token?: string; | ||
| token?: string; | ||
| refresh_token?: string; | ||
| expires_at?: string; | ||
| expires_in?: number; | ||
| } | ||
|
|
||
| export function getMachineId(): string { | ||
| const p = join(getConfigDir(), MACHINE_ID_FILENAME); | ||
| try { | ||
| if (existsSync(p)) { | ||
| const id = readFileSync(p, "utf-8").trim(); | ||
| if (id) return id; | ||
| } | ||
| } catch (e) { | ||
| if ((e as { code?: string })?.code !== "ENOENT") throw e; | ||
| } | ||
| const id = randomUUID(); | ||
| recordOwnedConfigPath(getConfigDir(), p); | ||
| if (!existsSync(getConfigDir())) mkdirSync(getConfigDir(), { recursive: true }); | ||
| writeFileSync(p, id + "\n", { mode: 0o600 }); | ||
| return id; | ||
| } | ||
|
|
||
| function sleep(ms: number, signal?: AbortSignal): Promise<void> { | ||
| return new Promise((resolve, reject) => { | ||
| if (signal?.aborted) return reject(new Error("Login cancelled")); | ||
| const t = setTimeout(resolve, ms); | ||
| signal?.addEventListener("abort", () => { | ||
| clearTimeout(t); | ||
| reject(new Error("Login cancelled")); | ||
| }, { once: true }); | ||
| }); | ||
| } | ||
|
|
||
| async function pollForToken(nonce: string, verifier: string, signal?: AbortSignal): Promise<OAuthCredentials> { | ||
| const deadline = Date.now() + POLL_TIMEOUT_MS; | ||
| const search = new URLSearchParams({ | ||
| nonce, | ||
| verifier, | ||
| challenge_method: "S256", | ||
| }); | ||
| const url = `${DEFAULT_OPENAPI_HOST}/api/v1/deviceToken/poll?${search}`; | ||
|
|
||
| while (Date.now() < deadline) { | ||
| if (signal?.aborted) throw new Error("Login cancelled"); | ||
| const res = await fetch(url, { | ||
| method: "GET", | ||
| headers: { Accept: "application/json" }, | ||
| signal, | ||
| }); | ||
| if (res.status === 404) { | ||
| await sleep(POLL_INTERVAL_MS, signal); | ||
| continue; | ||
| } | ||
| if (!res.ok) { | ||
| throw new Error(`Qoder device token poll failed: HTTP ${res.status}`); | ||
| } | ||
| const data = (await res.json()) as QoderDevicePollResponse; | ||
| const token = data.token || data.device_token; | ||
| if (!token) throw new Error("Qoder poll response missing token"); | ||
|
|
||
| let expires = Date.now() + 24 * 3600 * 1000; | ||
| if (typeof data.expires_at === "string") { | ||
| const parsed = new Date(data.expires_at).getTime(); | ||
| if (Number.isFinite(parsed) && parsed > 0) expires = parsed - OAUTH_EXPIRY_SKEW_MS; | ||
| } else if (typeof data.expires_in === "number" && Number.isFinite(data.expires_in)) { | ||
| expires = Date.now() + data.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS; | ||
| } | ||
|
|
||
| const accountId = data.user_id; | ||
| const email = data.user_name || data.email; | ||
|
|
||
| return { | ||
| access: token, | ||
| refresh: data.refresh_token || token, | ||
| expires, | ||
| ...(accountId ? { accountId } : {}), | ||
| ...(email ? { email } : {}), | ||
| source: "oauth", | ||
| }; | ||
| } | ||
| throw new Error("Qoder CN device authorization timed out"); | ||
| } | ||
|
|
||
| export async function loginQoderCn(ctrl: OAuthController): Promise<OAuthCredentials> { | ||
| const { verifier, challenge } = generatePKCE(); | ||
| const nonce = randomUUID(); | ||
| const machineId = getMachineId(); | ||
| const authUrl = `${DEFAULT_AUTH_HOST}/device/selectAccounts?challenge=${challenge}&challenge_method=S256&nonce=${nonce}&machine_id=${machineId}&client_id=${CLIENT_ID}`; | ||
|
|
||
| ctrl.onAuth?.({ | ||
| url: authUrl, | ||
| instructions: "Please complete the login in your browser", | ||
| }); | ||
|
|
||
| return pollForToken(nonce, verifier, ctrl.signal); | ||
| } | ||
|
|
||
| export async function refreshQoderCnToken(refreshToken: string, signal?: AbortSignal): Promise<OAuthCredentials> { | ||
| const res = await fetch(`${DEFAULT_OPENAPI_HOST}/api/v1/deviceToken/refresh`, { | ||
| method: "POST", | ||
| headers: { | ||
| "Content-Type": "application/json", | ||
| Accept: "application/json", | ||
| }, | ||
| body: JSON.stringify({ refresh_token: refreshToken }), | ||
| signal, | ||
| }); | ||
| if (!res.ok) { | ||
| throw new Error(`Qoder token refresh failed: HTTP ${res.status}`); | ||
| } | ||
| const data = (await res.json()) as QoderTokenRefreshResponse; | ||
| const token = data.device_token || data.token; | ||
| if (!token) throw new Error("Qoder refresh response missing token"); | ||
| let expires = Date.now() + 24 * 3600 * 1000; | ||
| if (typeof data.expires_at === "string") { | ||
| const parsed = new Date(data.expires_at).getTime(); | ||
| if (Number.isFinite(parsed) && parsed > 0) expires = parsed - OAUTH_EXPIRY_SKEW_MS; | ||
| } else if (typeof data.expires_in === "number" && Number.isFinite(data.expires_in)) { | ||
| expires = Date.now() + data.expires_in * 1000 - OAUTH_EXPIRY_SKEW_MS; | ||
| } | ||
|
Comment on lines
+151
to
+156
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- applicable repository guidance ---'
find /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732 -maxdepth 2 -type f \( -path '*/src/*' -o -path '*/learnings/*' -o -path '*/architecture/*' -o -name 'AGENTS.md' \) -print 2>/dev/null | sort | head -80
printf '%s\n' '--- target outline ---'
ast-grep outline src/oauth/qodercn.ts --match QoderTokenRefreshResponse --view expanded 2>/dev/null || true
printf '%s\n' '--- target source ---'
sed -n '1,220p' src/oauth/qodercn.ts
printf '%s\n' '--- direct expiry references ---'
rg -n -C 5 'QoderTokenRefreshResponse|expires_at|expires_in|OAUTH_EXPIRY_SKEW_MS|refresh' src/oauth/qodercn.ts src/oauth/index.tsRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- OAuth credential contract ---'
rg -n -C 8 'export interface OAuthCredentials|interface OAuthCredentials|type OAuthCredentials' src/oauth src
printf '%s\n' '--- validity and generic refresh flow ---'
sed -n '380,455p' src/oauth/index.ts
sed -n '708,765p' src/oauth/index.ts
printf '%s\n' '--- relevant learning excerpts ---'
sed -n '1,120p' /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/learnings/src.md 2>/dev/null || true
sed -n '1,120p' /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732/learnings/repo-wide.md 2>/dev/null || trueRepository: lidge-jun/opencodex Length of output: 11254 🏁 Script executed: #!/bin/bash
set -eu
printf '%s\n' '--- resolver tail ---'
sed -n '425,455p' src/oauth/index.ts
printf '%s\n' '--- merge helper ---'
rg -n -C 12 'function merged|const merged|merged\s*=' src/oauth/index.tsRepository: lidge-jun/opencodex Length of output: 5978 Handle When the refresh response omits 🤖 Prompt for AI Agents |
||
| return { access: token, refresh: data.refresh_token || refreshToken, expires, source: "oauth" }; | ||
| } | ||
|
|
||
| export function resolveQoderAccountContext(token: string): { machineId: string; accountId: string } { | ||
| const machineId = getMachineId(); | ||
| let accountId = ""; | ||
| try { | ||
| const authPath = join(getConfigDir(), "auth.json"); | ||
| if (existsSync(authPath)) { | ||
| const auth = JSON.parse(readFileSync(authPath, "utf-8")); | ||
| const accounts = auth.qodercn?.accounts || []; | ||
| const match = accounts.find((a: any) => a.credential?.access === token); | ||
| if (match?.credential?.accountId) { | ||
| accountId = match.credential.accountId; | ||
| } else if (accounts[0]?.credential?.accountId) { | ||
| accountId = accounts[0].credential.accountId; | ||
| } | ||
| } | ||
| } catch (_e) { | ||
| void _e; | ||
| } | ||
| return { machineId, accountId: accountId || "default-user" }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -224,6 +224,7 @@ export function providerConfigSeed(entry: ProviderRegistryEntry): OcxProviderCon | |
| ...(entry.keyOptional !== undefined ? { keyOptional: entry.keyOptional } : {}), | ||
| ...(entry.freeTier !== undefined ? { freeTier: entry.freeTier } : {}), | ||
| ...(entry.modelSuffixBracketStrip !== undefined ? { modelSuffixBracketStrip: entry.modelSuffixBracketStrip } : {}), | ||
| ...(entry.modelMap ? { modelMap: { ...entry.modelMap } } : {}), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Confirm whether Qoder CN setup or config loading can enrich a persisted provider
# that does not contain modelMap.
ast-grep outline src/providers/derive.ts --items all
rg -n -C 8 'providerConfigSeed|enrichProviderFromRegistry|modelMap' \
src/providers/derive.ts src/adapters/qodercn.ts
rg -n -C 8 'qodercn|providerConfigSeed|enrichProviderFromRegistry|modelMap' \
src/oauth/qodercn.ts src/oauth/index.ts testsRepository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- applicable repository guidance ---'
find /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732 -maxdepth 2 -type f -name '*.md' -print \
| sort
printf '%s\n' '--- derive.ts ---'
sed -n '170,255p' src/providers/derive.ts
printf '%s\n' '--- qodercn adapter ---'
sed -n '1,180p' src/adapters/qodercn.ts
printf '%s\n' '--- qodercn registry/oauth references ---'
rg -n -C 12 'qodercn|GLM-5\.3-Flash|gfmodel|modelMap' src/providers src/oauth src/config src/cli 2>/dev/null || true
printf '%s\n' '--- enrichment callers ---'
rg -n -C 8 'enrichProviderFromRegistry|providerConfigSeed' src tests \
-g '*.ts' | head -n 500Repository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- relevant convention and learning files ---'
find /tmp/coderabbit-repo-knowledge/lidge-jun-opencodex-7afea732 -maxdepth 2 -type f -name '*.md' -print \
| sort | while IFS= read -r f; do
case "$f" in
*/src/*|*/providers/*|*/adapters/*|*/oauth/*|*/learnings/*|*/architecture/*|*/AGENTS.md)
printf '\n### %s\n' "$f"
head -80 "$f"
;;
esac
done
printf '%s\n' '--- derive.ts relevant definitions ---'
rg -n -C 15 'function providerConfigSeed|const providerConfigSeed|function enrichProviderFromRegistry|const enrichProviderFromRegistry|modelMap' src/providers/derive.ts
printf '%s\n' '--- qodercn adapter relevant lines ---'
python3 - <<'PY'
from pathlib import Path
p = Path("src/adapters/qodercn.ts")
for n, line in enumerate(p.read_text().splitlines(), 1):
if any(x in line for x in ("modelMap", "modelId", "createQoderCnAdapter", "buildRequest")):
print(f"{n}: {line[:500]}")
PY
printf '%s\n' '--- registry and OAuth Qoder CN references ---'
rg -n -C 10 --glob '*.ts' --glob '!**/*.wasm.ts' \
'qodercn|GLM-5\.3-Flash|gfmodel|modelMap' src/providers/registry.ts src/oauth src/cli src/config tests/qodercn* tests/*provider* 2>/dev/null || true
printf '%s\n' '--- direct enrichment call sites ---'
rg -n -C 5 --glob '*.ts' \
'enrichProviderFromRegistry\(' src testsRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- derive.ts relevant definitions ---'
rg -n -C 15 'providerConfigSeed|enrichProviderFromRegistry|modelMap' src/providers/derive.ts
printf '%s\n' '--- qodercn adapter relevant lines ---'
python3 - <<'PY'
from pathlib import Path
for n, line in enumerate(Path("src/adapters/qodercn.ts").read_text().splitlines(), 1):
if any(x in line for x in ("modelMap", "modelId", "createQoderCnAdapter", "buildRequest")):
print(f"{n}: {line[:500]}")
PY
printf '%s\n' '--- Qoder CN registry and OAuth references ---'
rg -n -C 10 --glob '*.ts' --glob '!**/*.wasm.ts' \
'qodercn|GLM-5\.3-Flash|gfmodel|modelMap' \
src/providers/registry.ts src/oauth src/cli src/config tests/qodercn* 2>/dev/null || true
printf '%s\n' '--- enrichment call sites ---'
rg -n -C 5 --glob '*.ts' 'enrichProviderFromRegistry\(' src testsRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- exact enrichment body ---'
sed -n '448,535p' src/providers/derive.ts
printf '%s\n' '--- key-provider caller and OAuth config construction ---'
sed -n '1,75p' src/oauth/key-providers.ts
rg -n -C 12 'function oauthConfig|const oauthConfig|providerConfig|deriveOAuthProviderConfig|saveConfig|providers\[|enrichProviderFromRegistry' \
src/oauth/index.ts src/oauth/*.ts src/config.ts src/cli -g '*.ts'
printf '%s\n' '--- catalog enrichment path ---'
sed -n '390,435p' src/codex/catalog/provider-fetch.ts
rg -n -C 8 'gatherRoutedModels|gatherProvider|createRegisteredAdapter|routedProviderConfig|providerConfig' \
src/router.ts src/codex/catalog src/server -g '*.ts' | head -n 350
printf '%s\n' '--- Qoder adapter request construction ---'
sed -n '300,380p' src/adapters/qodercn.tsRepository: lidge-jun/opencodex Length of output: 50376 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- OAuth reconciliation field policy ---'
sed -n '920,1065p' src/oauth/index.ts
printf '%s\n' '--- OAuth login persistence path ---'
sed -n '1200,1295p' src/oauth/index.ts
printf '%s\n' '--- config loading and reconciliation callers ---'
rg -n -C 10 'reconcileOAuthProviders\(|loadConfig\(\)|upsertOAuthProvider\(' \
src tests -g '*.ts' | head -n 450
printf '%s\n' '--- provider-fetch adapter usage ---'
sed -n '400,455p' src/codex/catalog/provider-fetch.ts
rg -n -C 12 'createRegisteredAdapter|gatherRoutedModels|gatherProviderModels|liveModels' \
src/codex/catalog src/router.ts src/server -g '*.ts' | head -n 450
printf '%s\n' '--- Qoder adapter request body ---'
sed -n '315,370p' src/adapters/qodercn.tsRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- OAuth reconciliation fields ---'
sed -n '920,990p' src/oauth/index.ts
printf '%s\n' '--- OAuth reconciliation and startup callers ---'
rg -n -C 12 'reconcileOAuthProviders\(|loadConfig\(\)' src tests -g '*.ts' | head -n 350
printf '%s\n' '--- adapter construction in request routing ---'
rg -n -C 12 'createRegisteredAdapter|create.*Adapter|routedProviderConfig|providerConfig' \
src/router.ts src/server src/adapters -g '*.ts' | head -n 450
printf '%s\n' '--- provider-fetch control flow ---'
sed -n '380,455p' src/codex/catalog/provider-fetch.tsRepository: lidge-jun/opencodex Length of output: 50375 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- complete routed provider construction ---'
sed -n '273,430p' src/router.ts
printf '%s\n' '--- adapter resolution and Qoder request path ---'
sed -n '1,120p' src/adapters/registry.ts
rg -n -C 10 'resolveAdapter|createRegisteredAdapter|route\.provider|buildRequest\(' \
src/server src/adapters src/router.ts -g '*.ts' | head -n 350
printf '%s\n' '--- all OAuth reconciliation references ---'
rg -n --glob '*.ts' 'reconcileOAuthProviders' src tests
printf '%s\n' '--- Qoder adapter wire model assignment ---'
sed -n '340,360p' src/adapters/qodercn.tsRepository: lidge-jun/opencodex Length of output: 43586 Backfill
🤖 Prompt for AI AgentsSource: Path instructions |
||
| ...(entry.staticHeaders ? { headers: { ...entry.staticHeaders } } : {}), | ||
| ...(entry.defaultModel ? { defaultModel: entry.defaultModel } : {}), | ||
| ...(entry.models ? { models: [...entry.models] } : {}), | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 44939
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 50375
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 50375
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 30880
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 28382
🏁 Script executed:
Repository: lidge-jun/opencodex
Length of output: 44678
Preserve
ollama-nativefor existing custom providers.For a non-registry provider,
routedProviderConfig()preservesprovider.adapter(src/router.ts:273-277).resolveAdapter()then callscreateRegisteredAdapter(), which throwsUnknown adapter: ollama-nativewhen the registry no longer contains that ID (src/adapters/registry.ts:120-152). Existing self-hosted or custom providers can therefore stop serving after upgrade.Keep a legacy
ollama-nativeregistry entry. The built-inollama-cloudentry already resolves toopenai-chat; do not rewrite arbitrary custom destinations. The current conformance fixtures also still require"ollama-native"inAdapterWire.🤖 Prompt for AI Agents