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:
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user