From 41b7f13b2779ec2418fed5b64af69af030879f04 Mon Sep 17 00:00:00 2001 From: Paijo <14921983+oyi77@users.noreply.github.com> Date: Mon, 18 May 2026 18:34:00 +0700 Subject: [PATCH] feat(providers): add Replicate as free provider (#2364) Integrated into release/v3.8.0 --- open-sse/config/providerRegistry.ts | 37 ++++++++++++++ src/lib/oauth/utils/claudeAuthFile.ts | 4 +- src/lib/oauth/utils/geminiAuthImport.ts | 23 ++++----- src/shared/constants/providers.ts | 14 ++++++ tests/unit/claude-import-route.test.ts | 12 ++++- tests/unit/claudeAuthFile.test.ts | 12 ++++- tests/unit/claudeAuthImport.test.ts | 22 +++++++-- tests/unit/geminiAuthFile.test.ts | 5 +- tests/unit/geminiAuthImport.test.ts | 64 ++++++++++++++----------- 9 files changed, 135 insertions(+), 58 deletions(-) diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index 85f82731e2..f1a7101f6b 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -2684,6 +2684,43 @@ export const REGISTRY: Record = { ], }, + replicate: { + id: "replicate", + alias: "rep", + format: "openai", + executor: "default", + baseUrl: "https://openai-proxy.replicate.com/v1/chat/completions", + modelsUrl: "https://openai-proxy.replicate.com/v1/models", + authType: "apikey", + authHeader: "Authorization", + authPrefix: "Bearer", + passthroughModels: true, + defaultContextLength: 128000, + models: [ + { + id: "meta/meta-llama-3.1-405b-instruct", + name: "Llama 3.1 405B Instruct (Free)", + contextLength: 128000, + }, + { + id: "meta/meta-llama-3.1-70b-instruct", + name: "Llama 3.1 70B Instruct (Free)", + contextLength: 128000, + }, + { + id: "mistralai/mixtral-8x7b-instruct-v0.1", + name: "Mixtral 8x7B Instruct (Free)", + contextLength: 32768, + }, + { + id: "deepseek-ai/deepseek-r1", + name: "DeepSeek R1 (Free)", + contextLength: 65536, + supportsReasoning: true, + }, + ], + }, + hackclub: { id: "hackclub", alias: "hc", diff --git a/src/lib/oauth/utils/claudeAuthFile.ts b/src/lib/oauth/utils/claudeAuthFile.ts index 45bd5b877f..d6c853f1e1 100644 --- a/src/lib/oauth/utils/claudeAuthFile.ts +++ b/src/lib/oauth/utils/claudeAuthFile.ts @@ -182,9 +182,7 @@ async function resolveFreshClaudeConnection(connectionId: string): Promise controller.abort(), 8000); try { - const response = await fetch( - "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", - { - method: "POST", - headers: { - Authorization: `Bearer ${parsed.accessToken}`, - "Content-Type": "application/json", - }, - body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", platform: "linux" } }), - signal: controller.signal, - } - ); + const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { + method: "POST", + headers: { + Authorization: `Bearer ${parsed.accessToken}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", platform: "linux" } }), + signal: controller.signal, + }); if (!response.ok) { return { ...parsed, projectId: null }; @@ -197,7 +194,7 @@ export async function createConnectionFromAuthFile( ...toRecord(existing.providerSpecificData), scope: enriched.scope, tokenType: enriched.tokenType, - projectId: enriched.projectId ?? (toRecord(existing.providerSpecificData).projectId), + projectId: enriched.projectId ?? toRecord(existing.providerSpecificData).projectId, importedAt: new Date().toISOString(), }, }); diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 93db9d9fc0..08f7fd648c 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -887,6 +887,20 @@ export const APIKEY_PROVIDERS = { passthroughModels: true, authHint: "No auth required. API accepts any non-empty string as key for identification.", }, + replicate: { + id: "replicate", + alias: "rep", + name: "Replicate", + icon: "auto_awesome", + color: "#3B82F6", + textIcon: "RE", + website: "https://replicate.com", + hasFree: true, + freeNote: + "Free community models — Llama 3.1, Mixtral, DeepSeek R1. Passthrough for SDXL, Whisper, MusicGen.", + passthroughModels: true, + authHint: "Get API token at replicate.com/account/api-tokens", + }, hackclub: { id: "hackclub", alias: "hc", diff --git a/tests/unit/claude-import-route.test.ts b/tests/unit/claude-import-route.test.ts index 518eb6f85c..4dcbf059fc 100644 --- a/tests/unit/claude-import-route.test.ts +++ b/tests/unit/claude-import-route.test.ts @@ -161,11 +161,19 @@ function parseAndValidateClaudeAuth(raw: unknown) { const refreshToken = toNonEmptyString(oauthBlock.refreshToken); if (!accessToken) { - throw new ParseError("accessToken is missing or empty in claudeAiOauth", 400, "missing_access_token"); + throw new ParseError( + "accessToken is missing or empty in claudeAiOauth", + 400, + "missing_access_token" + ); } if (!refreshToken) { - throw new ParseError("refreshToken is missing or empty in claudeAiOauth", 400, "missing_refresh_token"); + throw new ParseError( + "refreshToken is missing or empty in claudeAiOauth", + 400, + "missing_refresh_token" + ); } let expiresAt: string | null = null; diff --git a/tests/unit/claudeAuthFile.test.ts b/tests/unit/claudeAuthFile.test.ts index 905aec7d23..e0a971e418 100644 --- a/tests/unit/claudeAuthFile.test.ts +++ b/tests/unit/claudeAuthFile.test.ts @@ -232,7 +232,10 @@ test("shouldRefreshClaudeConnection returns false when expiresAt > now + 10min", test("shouldRefreshClaudeConnection returns true when accessToken absent", () => { assert.equal( - shouldRefreshClaudeConnection({ accessToken: null, expiresAt: new Date(Date.now() + 3600 * 1000).toISOString() }), + shouldRefreshClaudeConnection({ + accessToken: null, + expiresAt: new Date(Date.now() + 3600 * 1000).toISOString(), + }), true ); }); @@ -276,7 +279,12 @@ test("write read-modify-write merges claudeAiOauth while preserving mcpOAuth", ( claudeAiOauth: { accessToken: "old-at", refreshToken: "old-rt", expiresAt: 0, scopes: [] }, }; - const newOauthBlock = { accessToken: "new-at", refreshToken: "new-rt", expiresAt: 1768000000000, scopes: ["user:inference"] }; + const newOauthBlock = { + accessToken: "new-at", + refreshToken: "new-rt", + expiresAt: 1768000000000, + scopes: ["user:inference"], + }; const merged = { ...existingDoc, claudeAiOauth: newOauthBlock }; // mcpOAuth is preserved diff --git a/tests/unit/claudeAuthImport.test.ts b/tests/unit/claudeAuthImport.test.ts index 51625cb59f..e2226cf0bd 100644 --- a/tests/unit/claudeAuthImport.test.ts +++ b/tests/unit/claudeAuthImport.test.ts @@ -50,7 +50,9 @@ interface CreateConnectionOptions { overwriteExisting?: boolean; } -function parseClaudeAuth(raw: unknown): ParsedClaudeAuth | { error: string; code: string; status: number } { +function parseClaudeAuth( + raw: unknown +): ParsedClaudeAuth | { error: string; code: string; status: number } { const doc = toRecord(raw); const oauthBlock = toRecord(doc.claudeAiOauth); @@ -58,11 +60,19 @@ function parseClaudeAuth(raw: unknown): ParsedClaudeAuth | { error: string; code const refreshToken = toNonEmptyString(oauthBlock.refreshToken); if (!accessToken) { - return { error: "accessToken is missing or empty in claudeAiOauth", code: "missing_access_token", status: 400 }; + return { + error: "accessToken is missing or empty in claudeAiOauth", + code: "missing_access_token", + status: 400, + }; } if (!refreshToken) { - return { error: "refreshToken is missing or empty in claudeAiOauth", code: "missing_refresh_token", status: 400 }; + return { + error: "refreshToken is missing or empty in claudeAiOauth", + code: "missing_refresh_token", + status: 400, + }; } let expiresAt: string | null = null; @@ -97,7 +107,8 @@ function checkCreateConnectionPreconditions( ): { error: string; code: string; status: number } | null { if (enriched.accountUUID && existingByAccountUUID && !options.overwriteExisting) { return { - error: "A Claude connection for this account already exists. Pass overwriteExisting: true to replace it.", + error: + "A Claude connection for this account already exists. Pass overwriteExisting: true to replace it.", code: "duplicate_account", status: 409, }; @@ -105,7 +116,8 @@ function checkCreateConnectionPreconditions( if (!enriched.email && !enriched.accountUUID && !options.overwriteExisting) { return { - error: "Could not verify the account identity (bootstrap failed and no email/accountUUID available). Pass overwriteExisting: true to import anyway.", + error: + "Could not verify the account identity (bootstrap failed and no email/accountUUID available). Pass overwriteExisting: true to import anyway.", code: "identity_unverified", status: 409, }; diff --git a/tests/unit/geminiAuthFile.test.ts b/tests/unit/geminiAuthFile.test.ts index 5a6fc15563..a6e9801982 100644 --- a/tests/unit/geminiAuthFile.test.ts +++ b/tests/unit/geminiAuthFile.test.ts @@ -404,10 +404,7 @@ test("mergeGoogleAccountsFile: noop when active already equals newEmail", async const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "gemini-test-")); const accountsPath = path.join(tmpDir, "google_accounts.json"); try { - await fs.writeFile( - accountsPath, - JSON.stringify({ active: "same@google.com", old: [] }) + "\n" - ); + await fs.writeFile(accountsPath, JSON.stringify({ active: "same@google.com", old: [] }) + "\n"); const result = await mergeGoogleAccountsFile(accountsPath, "same@google.com"); assert.equal(result.updated, false); assert.equal(result.savedBakPath, null); diff --git a/tests/unit/geminiAuthImport.test.ts b/tests/unit/geminiAuthImport.test.ts index a810ade55c..66084156d9 100644 --- a/tests/unit/geminiAuthImport.test.ts +++ b/tests/unit/geminiAuthImport.test.ts @@ -245,12 +245,15 @@ test("enrichWithLoadCodeAssist: returns projectId on success", async () => { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); try { - const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { - method: "POST", - headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, - body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", platform: "linux" } }), - signal: controller.signal, - }); + const response = await mockFetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, + body: JSON.stringify({ metadata: { ideType: "GEMINI_CLI", platform: "linux" } }), + signal: controller.signal, + } + ); if (!response.ok) return { ...p, projectId: null }; const data = (await response.json()) as Record; const projectId = @@ -268,7 +271,7 @@ test("enrichWithLoadCodeAssist: returns projectId on success", async () => { ({ ok: true, json: async () => ({ projectId: "my-gcp-project-123" }), - } as Response); + }) as Response; const enriched = await enrichWithMockFetch(parsed, mockFetch as typeof fetch); assert.equal(enriched.projectId, "my-gcp-project-123"); @@ -293,12 +296,15 @@ test("enrichWithLoadCodeAssist: returns projectId null on 401 (best-effort)", as const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), 8000); try { - const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { - method: "POST", - headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, - body: "{}", - signal: controller.signal, - }); + const response = await mockFetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, + body: "{}", + signal: controller.signal, + } + ); if (!response.ok) return { ...p, projectId: null }; const data = (await response.json()) as Record; const projectId = typeof data.projectId === "string" ? data.projectId || null : null; @@ -310,7 +316,7 @@ test("enrichWithLoadCodeAssist: returns projectId null on 401 (best-effort)", as } } - const mockFetch = async () => ({ ok: false, status: 401 } as Response); + const mockFetch = async () => ({ ok: false, status: 401 }) as Response; const enriched = await enrichWithMockFetch(parsed, mockFetch as typeof fetch); assert.equal(enriched.projectId, null); @@ -332,12 +338,15 @@ test("enrichWithLoadCodeAssist: returns projectId null on network error (best-ef mockFetch: typeof fetch ): Promise<{ projectId: string | null } & ParsedGeminiAuth> { try { - const response = await mockFetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { - method: "POST", - headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, - body: "{}", - signal: new AbortController().signal, - }); + const response = await mockFetch( + "https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", + { + method: "POST", + headers: { Authorization: `Bearer ${p.accessToken}`, "Content-Type": "application/json" }, + body: "{}", + signal: new AbortController().signal, + } + ); if (!response.ok) return { ...p, projectId: null }; return { ...p, projectId: null }; } catch { @@ -389,10 +398,7 @@ test("createConnectionFromAuthFile: allows update when exists + overwrite=true", }); test("createConnectionFromAuthFile: throws 409 identity_unverified when no email + overwrite=false", () => { - function checkIdentity( - resolvedEmail: string | null, - overwriteExisting: boolean - ): string | null { + function checkIdentity(resolvedEmail: string | null, overwriteExisting: boolean): string | null { if (!resolvedEmail && !overwriteExisting) return "identity_unverified"; return null; } @@ -401,10 +407,7 @@ test("createConnectionFromAuthFile: throws 409 identity_unverified when no email }); test("createConnectionFromAuthFile: allows create without email when overwrite=true", () => { - function checkIdentity( - resolvedEmail: string | null, - overwriteExisting: boolean - ): string | null { + function checkIdentity(resolvedEmail: string | null, overwriteExisting: boolean): string | null { if (!resolvedEmail && !overwriteExisting) return "identity_unverified"; return null; } @@ -416,7 +419,10 @@ test("createConnectionFromAuthFile: email from options.email takes precedence ov const resolveEmail = (optionsEmail: string | undefined, enrichedEmail: string | null) => optionsEmail || enrichedEmail; - assert.equal(resolveEmail("override@example.com", "original@example.com"), "override@example.com"); + assert.equal( + resolveEmail("override@example.com", "original@example.com"), + "override@example.com" + ); assert.equal(resolveEmail(undefined, "original@example.com"), "original@example.com"); assert.equal(resolveEmail(undefined, null), null); });