feat(providers): add Replicate as free provider (#2364)

Integrated into release/v3.8.0
This commit is contained in:
Paijo
2026-05-18 18:34:00 +07:00
committed by GitHub
parent 6c488d6d2a
commit 41b7f13b27
9 changed files with 135 additions and 58 deletions

View File

@@ -2684,6 +2684,43 @@ export const REGISTRY: Record<string, RegistryEntry> = {
],
},
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",

View File

@@ -182,9 +182,7 @@ async function resolveFreshClaudeConnection(connectionId: string): Promise<Claud
}
if (connection.authType !== "oauth") {
throw new ClaudeAuthFileError(
"Only OAuth Claude connections support credentials.json export"
);
throw new ClaudeAuthFileError("Only OAuth Claude connections support credentials.json export");
}
if (!shouldRefreshClaudeConnection(connection)) {

View File

@@ -119,18 +119,15 @@ export async function enrichWithLoadCodeAssist(
const timer = setTimeout(() => 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(),
},
});

View File

@@ -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",

View File

@@ -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;

View File

@@ -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

View File

@@ -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,
};

View File

@@ -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);

View File

@@ -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<string, unknown>;
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<string, unknown>;
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);
});