feat(qoder): support PAT (Personal Access Token) connections end-to-end

Adds pt-... token auth as an alternative to OAuth device flow. A PAT can't
sign COSY requests directly, so it's exchanged for a short-lived job token
(jt-...) plus userId via openapi.qoder.sh, then used for signing.

Also fixes job-token traffic (jt-...) being rejected by api3.qoder.sh with
403 "Login expired" — the official qodercli serves jt- traffic from
api2.qoder.sh instead, so buildUrl/model-list routing now branches on it.

Quota usage and the dashboard add-key modal are updated to resolve PAT
credentials and label the field correctly, and bulk-add now validates
each key so it gets a real testStatus instead of a hardcoded "unknown".
This commit is contained in:
mannnrachman
2026-08-05 11:16:01 +07:00
committed by decolua
parent 3292dfc102
commit d433c0b295
8 changed files with 189 additions and 17 deletions

View File

@@ -13,10 +13,10 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const isOllamaLocal = provider === "ollama-local";
const isCookie = authType === "cookie";
const isXaiApiKey = provider === "xai" && !isCookie;
const credentialLabel = isCookie ? "Cookie Value" : "API Key";
const credentialLabel = isCookie ? "Cookie Value" : provider === "qoder" ? "Personal Access Token (PAT)" : "API Key";
const credentialPlaceholder = isCookie
? (provider === "grok-web" ? "sso=xxxxx... or just the raw value" : "eyJhbGciOi...")
: (isXaiApiKey ? "xai-..." : "");
: (isXaiApiKey ? "xai-..." : provider === "qoder" ? "pt-..." : "");
const isAzure = provider === "azure";
const isCloudflareAi = provider === "cloudflare-ai";
@@ -44,7 +44,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
const [saving, setSaving] = useState(false);
const bulkPlaceholder = isCloudflareAi
? `name1|sk-key1|acc123456\nname2|sk-key2|def789012\nsk-key-only-auto-named`
: BULK_PLACEHOLDER;
: provider === "qoder"
? `name1|pt-xxxxx\nname2|pt-yyyyy\npt-only-auto-named`
: BULK_PLACEHOLDER;
const [mode, setMode] = useState("single"); // "single" | "bulk"
const [bulkText, setBulkText] = useState("");
@@ -145,6 +147,21 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
let failed = 0;
for (const entry of plan) {
try {
// Validate each key before saving so bulk-added connections get a
// real status (active/unknown) like single adds, instead of a
// hardcoded "unknown" that never flips until a manual test.
let isValid = false;
try {
const vres = await fetch("/api/providers/validate", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ provider, apiKey: entry.apiKey }),
});
const vdata = await vres.json().catch(() => ({}));
isValid = !!vdata.valid;
} catch {
isValid = false;
}
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -153,7 +170,7 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
apiKey: entry.apiKey,
name: entry.name,
priority: 1,
testStatus: "unknown",
testStatus: isValid ? "active" : "unknown",
...(entry.providerSpecificData ? { providerSpecificData: entry.providerSpecificData } : {}),
}),
});
@@ -184,7 +201,9 @@ export default function AddApiKeyModal({ isOpen, provider, providerName, isCompa
<p className="text-xs text-text-muted">
{isCloudflareAi
? <>One key per line. Format: <code>name|apiKey|accountId</code> or just <code>apiKey</code> (auto-named by index).</>
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
: provider === "qoder"
? <>One PAT per line. Format: <code>name|pt-...</code> or just <code>pt-...</code> (auto-named by index).</>
: <>One key per line. Format: <code>name|apiKey</code> or just <code>apiKey</code> (auto-named by index).</>
}
</p>
<textarea

View File

@@ -161,6 +161,7 @@ export default function ProviderDetailPage() {
const apiKeyConnectionLabel =
providerId === "xai" ? "xAI API Key"
: providerId === "kimi" ? "Kimi API Key"
: providerId === "qoder" ? "PAT"
: "API Key";
// Resolve suffix "(level)" for a model when a thinking level is picked and the model supports it.
const resolveThinkingSuffix = (modelId) => {

View File

@@ -356,6 +356,7 @@ const PROVIDER_MODELS_CONFIG = {
customResolver: async (connection) => {
const credentials = {
accessToken: connection.accessToken,
apiKey: connection.apiKey,
refreshToken: connection.refreshToken,
email: connection.email,
displayName: connection.displayName,