mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 03:56:18 +00:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b731bc19e2 | ||
|
|
fedf017e25 | ||
|
|
f4a9b93013 |
@@ -350,7 +350,6 @@
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
||||
+4
-4
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"nodeModules": {
|
||||
"x86_64-linux": "sha256-QWLIdvu985FH5I9cZJOAuoeFeXU+4Jx9RzBB9RPoeeQ=",
|
||||
"aarch64-linux": "sha256-SSzGD5hMj2vFvyw+dUPR9g/ZH6qhs0ZyZ/DnltZt3N8=",
|
||||
"aarch64-darwin": "sha256-CeFUxiV+e8pKho+YcSclC3soQBogoxNMxwyIMztAExU=",
|
||||
"x86_64-darwin": "sha256-FYwcACzU72y0+KtOpFfU7ndak8vMasqMgd5NLS6+XtY="
|
||||
"x86_64-linux": "sha256-Q7BQ46mKePJtaKzhHxahIXy/pZczPmm5cQuBDrgd2Bc=",
|
||||
"aarch64-linux": "sha256-pqk4iUhXzEc4ei9zpeGpPjX7Q6pxH1K5rgotD5Wf91s=",
|
||||
"aarch64-darwin": "sha256-1q3mK5zLqQA0vz7KErDOkjeAnmsTReI0lhBJfIobC/E=",
|
||||
"x86_64-darwin": "sha256-dBMQ6tZxt5VjgWTZELHgPk6fVhBfNYfmY+AnQ3iJ88Q="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Effect, Schema } from "effect"
|
||||
import type { ProviderPackage } from "../provider-package.js"
|
||||
import { OpenAIChat } from "../protocols/openai-chat.js"
|
||||
import { ProviderShared } from "../protocols/shared.js"
|
||||
import { AuthOptions, type ProviderAuthOption } from "../route/auth-options.js"
|
||||
import { Route, type RouteDefaultsInput } from "../route/client.js"
|
||||
import { Endpoint } from "../route/endpoint.js"
|
||||
import { Framing } from "../route/framing.js"
|
||||
import { Protocol } from "../route/protocol.js"
|
||||
import { ProviderID, type ModelID, type LLMRequest } from "../schema/index.js"
|
||||
import { profiles } from "./openai-compatible-profile.js"
|
||||
import type { OpenAIProviderOptionsInput } from "./openai-options.js"
|
||||
|
||||
export const id = ProviderID.make("groq")
|
||||
|
||||
export type ProviderOptions = Pick<OpenAIProviderOptionsInput, "reasoningEffort"> & {
|
||||
/** Controls visible reasoning on GPT-OSS; other models always use parsed reasoning. */
|
||||
readonly includeReasoning?: boolean
|
||||
readonly parallelToolCalls?: boolean
|
||||
readonly serviceTier?: "on_demand" | "flex" | "auto" | "performance" | (string & {})
|
||||
readonly user?: string
|
||||
}
|
||||
|
||||
export type LanguageModelOptions = Omit<RouteDefaultsInput, "providerOptions"> &
|
||||
ProviderAuthOption<"optional"> & {
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
export interface Settings extends ProviderPackage.Settings {
|
||||
readonly apiKey?: string
|
||||
readonly baseURL?: string
|
||||
readonly providerOptions?: ProviderOptions
|
||||
}
|
||||
|
||||
const Options = Schema.Struct({
|
||||
includeReasoning: Schema.optional(Schema.Boolean),
|
||||
parallelToolCalls: Schema.optional(Schema.Boolean),
|
||||
serviceTier: Schema.optional(Schema.String),
|
||||
user: Schema.optional(Schema.String),
|
||||
})
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: "groq-chat",
|
||||
body: {
|
||||
schema: Schema.Struct({
|
||||
...OpenAIChat.bodyFields,
|
||||
reasoning_format: Schema.optional(Schema.Literal("parsed")),
|
||||
include_reasoning: Schema.optional(Schema.Boolean),
|
||||
parallel_tool_calls: Schema.optional(Schema.Boolean),
|
||||
service_tier: Schema.optional(Schema.String),
|
||||
user: Schema.optional(Schema.String),
|
||||
}),
|
||||
from: Effect.fn("Groq.fromRequest")(function* (request: LLMRequest) {
|
||||
const options = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Options))(
|
||||
request.providerOptions ?? {},
|
||||
)
|
||||
const gptOSS = request.model.id.startsWith("openai/gpt-oss-")
|
||||
return {
|
||||
...(yield* OpenAIChat.fromRequest(request)),
|
||||
reasoning_format: gptOSS ? undefined : ("parsed" as const),
|
||||
include_reasoning: gptOSS ? options.includeReasoning : undefined,
|
||||
parallel_tool_calls: options.parallelToolCalls,
|
||||
service_tier: options.serviceTier,
|
||||
user: options.user,
|
||||
}
|
||||
}),
|
||||
},
|
||||
stream: OpenAIChat.protocol.stream,
|
||||
})
|
||||
|
||||
export const route = Route.make({
|
||||
id: "groq-chat",
|
||||
provider: id,
|
||||
providerMetadataKey: "openai",
|
||||
protocol,
|
||||
endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
|
||||
framing: Framing.sse,
|
||||
})
|
||||
|
||||
export const configure = (input: LanguageModelOptions = {}) => {
|
||||
const { apiKey: _apiKey, auth: _auth, baseURL, ...defaults } = input
|
||||
const configured = route.with({
|
||||
...defaults,
|
||||
endpoint: { baseURL: baseURL ?? profiles.groq.baseURL },
|
||||
auth: AuthOptions.bearer(input, "GROQ_API_KEY"),
|
||||
})
|
||||
return {
|
||||
id,
|
||||
model: (modelID: string | ModelID) =>
|
||||
configured.model<ProviderOptions>({
|
||||
id: modelID,
|
||||
compatibility: {
|
||||
maxTokensField: "max_completion_tokens",
|
||||
reasoningField: "reasoning",
|
||||
requireReasoning: false,
|
||||
supportsStore: false,
|
||||
supportsStrictMode: false,
|
||||
},
|
||||
}),
|
||||
configure,
|
||||
}
|
||||
}
|
||||
|
||||
export const provider = configure()
|
||||
|
||||
export const model: ProviderPackage.Definition<Settings, ProviderOptions>["model"] = (modelID, settings) =>
|
||||
configure({
|
||||
apiKey: settings.apiKey,
|
||||
baseURL: settings.baseURL,
|
||||
headers: settings.headers === undefined ? undefined : { ...settings.headers },
|
||||
http: settings.body === undefined ? undefined : { body: { ...settings.body } },
|
||||
providerOptions: settings.providerOptions,
|
||||
}).model(modelID)
|
||||
|
||||
export * as Groq from "./groq.js"
|
||||
@@ -12,6 +12,7 @@ export * as GoogleVertex from "./google-vertex.js"
|
||||
export * as GoogleVertexChat from "./google-vertex-chat.js"
|
||||
export * as GoogleVertexMessages from "./google-vertex-messages.js"
|
||||
export * as GoogleVertexResponses from "./google-vertex-responses.js"
|
||||
export * as Groq from "./groq.js"
|
||||
export * as OpenAI from "./openai.js"
|
||||
export * as OpenAICompatible from "./openai-compatible.js"
|
||||
export * as OpenAICompatibleResponses from "./openai-compatible-responses.js"
|
||||
|
||||
+47
File diff suppressed because one or more lines are too long
Vendored
+47
File diff suppressed because one or more lines are too long
+29
File diff suppressed because one or more lines are too long
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"model": "openai/gpt-oss-20b",
|
||||
"tags": ["prefix:groq-chat", "provider:groq", "protocol:groq-chat", "text", "usage"],
|
||||
"name": "groq-chat/streams-text-with-usage",
|
||||
"recordedAt": "2026-08-26T14:40:09.833Z"
|
||||
},
|
||||
"interactions": [
|
||||
{
|
||||
"transport": "http",
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"url": "https://api.groq.com/openai/v1/chat/completions",
|
||||
"headers": {
|
||||
"content-type": "application/json"
|
||||
},
|
||||
"body": "{\"model\":\"openai/gpt-oss-20b\",\"messages\":[{\"role\":\"user\",\"content\":\"Reply with exactly one word: hello\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true},\"reasoning_effort\":\"low\",\"max_completion_tokens\":512,\"include_reasoning\":false,\"service_tier\":\"on_demand\",\"user\":\"recorded-test\"}"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"content-type": "text/event-stream"
|
||||
},
|
||||
"body": "data: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"logprobs\":null,\"finish_reason\":null}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"seed\":94036161}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"hello\"},\"logprobs\":null,\"finish_reason\":null}]}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"x_groq\":{\"id\":\"req_01m0z87915eep9bpf10gg7331e\",\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}},\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}}}\n\ndata: {\"id\":\"chatcmpl-660048b0-3c99-4215-a582-e116b77eb881\",\"object\":\"chat.completion.chunk\",\"created\":1787755209,\"model\":\"openai/gpt-oss-20b\",\"system_fingerprint\":\"fp_66891002f6\",\"choices\":[],\"usage\":{\"queue_time\":0.10886435,\"prompt_tokens\":78,\"prompt_time\":0.003693734,\"completion_tokens\":20,\"completion_time\":0.020459983,\"total_tokens\":98,\"total_time\":0.024153717,\"completion_tokens_details\":{\"reasoning_tokens\":10}},\"service_tier\":\"on_demand\"}\n\ndata: [DONE]\n\n"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -29,6 +29,7 @@ describe("provider package entrypoints", () => {
|
||||
import("@opencode-ai/ai/providers/togetherai"),
|
||||
import("@opencode-ai/ai/providers/cerebras"),
|
||||
import("@opencode-ai/ai/providers/deepinfra"),
|
||||
import("@opencode-ai/ai/providers/groq"),
|
||||
])
|
||||
|
||||
for (const module of modules) expect(module.model).toBeFunction()
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { configure } from "@opencode-ai/ai/providers/groq"
|
||||
import { describe, expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LLM, LLMEvent, LLMRequest, LLMResponse, Message, ToolChoice, ToolDefinition } from "../../src/index.js"
|
||||
import { LLMClient } from "../../src/route.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { recordedTests } from "../recorded-test.js"
|
||||
|
||||
const apiKey = process.env.GROQ_API_KEY ?? "fixture"
|
||||
const recorded = recordedTests({
|
||||
prefix: "groq-chat",
|
||||
provider: "groq",
|
||||
protocol: "groq-chat",
|
||||
requires: ["GROQ_API_KEY"],
|
||||
})
|
||||
|
||||
const weather = ToolDefinition.make({
|
||||
name: "lookup_weather",
|
||||
description: "Look up the current weather for a city",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { city: { type: "string", enum: ["Paris", "London"] } },
|
||||
required: ["city"],
|
||||
additionalProperties: false,
|
||||
},
|
||||
})
|
||||
|
||||
describe("Groq recorded", () => {
|
||||
recorded.effect.with(
|
||||
"streams text with usage",
|
||||
{ tags: ["text", "usage"], metadata: { model: "openai/gpt-oss-20b" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: configure({
|
||||
apiKey,
|
||||
providerOptions: {
|
||||
includeReasoning: false,
|
||||
reasoningEffort: "low",
|
||||
serviceTier: "on_demand",
|
||||
user: "recorded-test",
|
||||
},
|
||||
}).model("openai/gpt-oss-20b"),
|
||||
prompt: "Reply with exactly one word: hello",
|
||||
generation: { maxTokens: 512 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body).toMatchObject({
|
||||
max_completion_tokens: 512,
|
||||
stream_options: { include_usage: true },
|
||||
include_reasoning: false,
|
||||
service_tier: "on_demand",
|
||||
user: "recorded-test",
|
||||
})
|
||||
expect(compiled.body.max_tokens).toBeUndefined()
|
||||
expect(compiled.body.store).toBeUndefined()
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.text.toLowerCase().trim()).toBe("hello")
|
||||
expect(response.reasoning).toBe("")
|
||||
expect(response.events.some(LLMEvent.is.textDelta)).toBe(true)
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
|
||||
for (const item of [
|
||||
{
|
||||
name: "continues Qwen parallel tool calls",
|
||||
model: configure({ apiKey, providerOptions: { parallelToolCalls: true, reasoningEffort: "none" } }).model(
|
||||
"qwen/qwen3.6-27b",
|
||||
),
|
||||
cities: ["Paris", "London"],
|
||||
reasoning: false,
|
||||
},
|
||||
{
|
||||
name: "replays GPT OSS reasoning through a tool loop",
|
||||
model: configure({ apiKey, providerOptions: { includeReasoning: true, reasoningEffort: "low" } }).model(
|
||||
"openai/gpt-oss-20b",
|
||||
),
|
||||
cities: ["Paris"],
|
||||
reasoning: true,
|
||||
},
|
||||
]) {
|
||||
recorded.effect.with(
|
||||
item.name,
|
||||
{
|
||||
tags: ["tool", "tool-loop", "usage", item.reasoning ? "reasoning" : "parallel"],
|
||||
metadata: { model: item.model.id },
|
||||
},
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: item.model,
|
||||
prompt: `Look up the current weather in ${item.cities.join(" and ")}. Call lookup_weather once for each city in the same response before answering. After receiving all results, report each city's weather in one short sentence.`,
|
||||
tools: [weather],
|
||||
toolChoice: "required",
|
||||
generation: { maxTokens: 1536 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body.stream_options).toEqual({ include_usage: true })
|
||||
expect(compiled.body.store).toBeUndefined()
|
||||
expect(compiled.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
|
||||
expect(compiled.body.tools[0].function.strict).toBeUndefined()
|
||||
if (!item.reasoning) expect(compiled.body.parallel_tool_calls).toBe(true)
|
||||
|
||||
const first = yield* LLMClient.generate(request)
|
||||
expect(first.finishReason.normalized).toBe("tool-calls")
|
||||
expect(first.toolCalls).toHaveLength(item.cities.length)
|
||||
expect(new Set(first.toolCalls.map((call) => call.id)).size).toBe(item.cities.length)
|
||||
expect(first.toolCalls.map((call) => call.input)).toEqual(
|
||||
expect.arrayContaining(item.cities.map((city) => ({ city }))),
|
||||
)
|
||||
expect(first.toolCalls.every((call) => call.name === "lookup_weather")).toBe(true)
|
||||
expectUsage(first)
|
||||
if (item.reasoning) {
|
||||
expect(first.reasoning.length).toBeGreaterThan(0)
|
||||
expect(first.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
}
|
||||
|
||||
const followUp = LLMRequest.update(request, {
|
||||
toolChoice: ToolChoice.make("none"),
|
||||
messages: [
|
||||
...request.messages,
|
||||
first.message,
|
||||
...first.toolCalls.map((call) =>
|
||||
Message.tool({ id: call.id, name: call.name, result: { condition: "sunny", temperature: "18C" } }),
|
||||
),
|
||||
],
|
||||
})
|
||||
const replay = yield* compileRequest(followUp)
|
||||
if (item.reasoning) {
|
||||
expect(replay.body.messages).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ role: "assistant", reasoning: first.reasoning })]),
|
||||
)
|
||||
}
|
||||
expect(replay.body.reasoning_format).toBe(item.reasoning ? undefined : "parsed")
|
||||
|
||||
const second = yield* LLMClient.generate(followUp)
|
||||
expect(second.finishReason.normalized).toBe("stop")
|
||||
expect(second.toolCalls).toHaveLength(0)
|
||||
expect(second.text.toLowerCase()).toContain("sunny")
|
||||
item.cities.forEach((city) => expect(second.text).toContain(city))
|
||||
expectUsage(second)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
}
|
||||
|
||||
recorded.effect.with(
|
||||
"streams Qwen parsed reasoning",
|
||||
{ tags: ["reasoning", "usage"], metadata: { model: "qwen/qwen3.6-27b" } },
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const request = LLM.request({
|
||||
model: configure({
|
||||
apiKey,
|
||||
providerOptions: { reasoningEffort: "default" },
|
||||
}).model("qwen/qwen3.6-27b"),
|
||||
prompt:
|
||||
"What is 173 multiplied by 219? Think through the arithmetic, then reply with only the final integer.",
|
||||
generation: { maxTokens: 2048 },
|
||||
})
|
||||
const compiled = yield* compileRequest(request)
|
||||
expect(compiled.body).toMatchObject({ reasoning_format: "parsed", reasoning_effort: "default" })
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
|
||||
const response = yield* LLMClient.generate(request)
|
||||
expect(response.text.replaceAll(",", "").trim()).toBe("37887")
|
||||
expect(response.text).not.toContain("<think>")
|
||||
expect(response.reasoning.length).toBeGreaterThan(0)
|
||||
expect(response.events.some(LLMEvent.is.reasoningDelta)).toBe(true)
|
||||
expectUsage(response)
|
||||
}),
|
||||
60_000,
|
||||
)
|
||||
})
|
||||
|
||||
function expectUsage(response: LLMResponse) {
|
||||
expect(response.usage).toBeDefined()
|
||||
expect(response.usage?.inputTokens).toBeGreaterThan(0)
|
||||
expect(response.usage?.outputTokens).toBeGreaterThan(0)
|
||||
expect(response.events.filter(LLMEvent.is.finish)).toHaveLength(1)
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect } from "bun:test"
|
||||
import { Effect } from "effect"
|
||||
import { LanguageModel, LLM, Message } from "../../src/index.js"
|
||||
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
|
||||
import { Groq } from "../../src/providers/groq.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { weatherTool } from "../recorded-scenarios.js"
|
||||
|
||||
it.effect("Groq reuses Chat streaming and defaults to parsed reasoning", () =>
|
||||
Effect.gen(function* () {
|
||||
expect(Groq.protocol.stream).toBe(OpenAIChat.protocol.stream)
|
||||
const model = Groq.configure({ apiKey: "fixture" }).model("llama-3.3-70b-versatile")
|
||||
expect(model.route.endpoint.baseURL).toBe("https://api.groq.com/openai/v1")
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Hello", tools: [weatherTool], generation: { maxTokens: 64 } }),
|
||||
)
|
||||
expect(compiled.body).toMatchObject({
|
||||
max_completion_tokens: 64,
|
||||
stream_options: { include_usage: true },
|
||||
reasoning_format: "parsed",
|
||||
})
|
||||
for (const key of ["store", "max_tokens", "include_reasoning", "parallel_tool_calls", "service_tier", "user"])
|
||||
expect(compiled.body[key]).toBeUndefined()
|
||||
expect(compiled.body.tools?.[0]?.function).not.toHaveProperty("strict")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq lowers its own options for custom catalog identities and endpoints", () =>
|
||||
Effect.gen(function* () {
|
||||
const model = LanguageModel.update(
|
||||
Groq.model("qwen/qwen3.6-27b", {
|
||||
apiKey: "fixture",
|
||||
baseURL: "https://gateway.example/v1",
|
||||
headers: { "x-client": "test" },
|
||||
body: { custom: "value" },
|
||||
providerOptions: {
|
||||
reasoningEffort: "default",
|
||||
parallelToolCalls: true,
|
||||
serviceTier: "flex",
|
||||
user: "test-user",
|
||||
},
|
||||
}),
|
||||
{ provider: "custom-groq" },
|
||||
)
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model, prompt: "Hello", providerOptions: { parallelToolCalls: false, includeReasoning: false } }),
|
||||
)
|
||||
expect(model.route.endpoint.baseURL).toBe("https://gateway.example/v1")
|
||||
expect(model.route.defaults.headers).toEqual({ "x-client": "test" })
|
||||
expect(model.route.defaults.http?.body).toEqual({ custom: "value" })
|
||||
expect(compiled.body).toMatchObject({
|
||||
reasoning_effort: "default",
|
||||
reasoning_format: "parsed",
|
||||
parallel_tool_calls: false,
|
||||
service_tier: "flex",
|
||||
user: "test-user",
|
||||
})
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
for (const key of ["reasoningFormat", "reasoningEffort", "parallelToolCalls", "serviceTier"])
|
||||
expect(compiled.body).not.toHaveProperty(key)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq replays reasoning only when present and preserves explicit reasoning exclusion", () =>
|
||||
Effect.gen(function* () {
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({
|
||||
model: Groq.configure({ apiKey: "fixture" }).model("openai/gpt-oss-20b"),
|
||||
messages: [
|
||||
Message.user("Think"),
|
||||
Message.assistant([
|
||||
{ type: "reasoning", text: "Thinking" },
|
||||
{ type: "text", text: "Answer" },
|
||||
]),
|
||||
Message.user("Again"),
|
||||
Message.assistant("Answer only"),
|
||||
Message.user("Continue"),
|
||||
],
|
||||
providerOptions: { reasoningEffort: "low", includeReasoning: false },
|
||||
}),
|
||||
)
|
||||
expect(compiled.body).toMatchObject({ reasoning_effort: "low", include_reasoning: false })
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
expect(compiled.body.messages[1]).toMatchObject({ reasoning: "Thinking", content: "Answer" })
|
||||
expect(compiled.body.messages[1]).not.toHaveProperty("reasoning_content")
|
||||
expect(compiled.body.messages[3]).not.toHaveProperty("reasoning")
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq omits reasoning_format for the GPT-OSS family by default", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const id of ["openai/gpt-oss-20b", "openai/gpt-oss-120b", "openai/gpt-oss-safeguard-20b"]) {
|
||||
const compiled = yield* compileRequest(
|
||||
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model(id), prompt: "Hello" }),
|
||||
)
|
||||
expect(compiled.body.reasoning_format).toBeUndefined()
|
||||
expect(compiled.body.include_reasoning).toBeUndefined()
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("Groq validates option types", () =>
|
||||
Effect.gen(function* () {
|
||||
for (const providerOptions of [{ includeReasoning: "false" }, { parallelToolCalls: "false" }]) {
|
||||
const error = yield* compileRequest(
|
||||
LLM.request({ model: Groq.configure({ apiKey: "fixture" }).model("qwen"), prompt: "Hello", providerOptions }),
|
||||
).pipe(Effect.flip)
|
||||
expect(error.reason._tag).toBe("InvalidRequest")
|
||||
}
|
||||
}),
|
||||
)
|
||||
@@ -2,7 +2,7 @@ import { describe, expect } from "bun:test"
|
||||
import { ConfigProvider, Effect } from "effect"
|
||||
import { HttpClientRequest } from "effect/unstable/http"
|
||||
import { LLM, Message, ToolDefinition } from "../../src/index.js"
|
||||
import { Cerebras, DeepInfra, TogetherAI } from "../../src/providers/index.js"
|
||||
import { Cerebras, DeepInfra, Groq, TogetherAI } from "../../src/providers/index.js"
|
||||
import { compileRequest } from "../../src/route/client.js"
|
||||
import { it } from "../lib/effect.js"
|
||||
import { dynamicResponse } from "../lib/http.js"
|
||||
@@ -155,6 +155,12 @@ describe("native OpenAI-compatible providers", () => {
|
||||
token: "deepinfra-secret",
|
||||
url: "https://api.deepinfra.com/v1/openai/chat/completions",
|
||||
},
|
||||
{
|
||||
model: Groq.configure().model("llama"),
|
||||
env: { GROQ_API_KEY: "groq-secret" },
|
||||
token: "groq-secret",
|
||||
url: "https://api.groq.com/openai/v1/chat/completions",
|
||||
},
|
||||
]
|
||||
|
||||
yield* Effect.forEach(scenarios, (scenario) =>
|
||||
|
||||
@@ -105,7 +105,6 @@
|
||||
"@ai-sdk/cohere": "3.0.27",
|
||||
"@ai-sdk/gateway": "3.0.104",
|
||||
"@ai-sdk/google-vertex": "4.0.128",
|
||||
"@ai-sdk/groq": "3.0.31",
|
||||
"@ai-sdk/mistral": "3.0.51",
|
||||
"@ai-sdk/openai-compatible": "2.0.41",
|
||||
"@ai-sdk/perplexity": "3.0.26",
|
||||
|
||||
@@ -55,6 +55,7 @@ export function map(input: MapInput): Mapping | undefined {
|
||||
}
|
||||
case "@ai-sdk/cerebras":
|
||||
case "@ai-sdk/deepinfra":
|
||||
case "@ai-sdk/groq":
|
||||
case "@ai-sdk/togetherai":
|
||||
return {
|
||||
package: `@opencode-ai/ai/providers/${input.packageName.slice("@ai-sdk/".length)}`,
|
||||
|
||||
@@ -337,6 +337,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@ai-sdk/deepinfra" ||
|
||||
name === "@ai-sdk/openai-compatible" ||
|
||||
name === "@ai-sdk/google" ||
|
||||
name === "@ai-sdk/groq" ||
|
||||
name === "@ai-sdk/togetherai" ||
|
||||
name === "@ai-sdk/xai" ||
|
||||
name === "@openrouter/ai-sdk-provider" ||
|
||||
@@ -349,6 +350,7 @@ function usesAPIKeyAuth(packageName: string | undefined) {
|
||||
name === "@opencode-ai/ai/providers/deepinfra" ||
|
||||
name === "@opencode-ai/ai/providers/openai-compatible" ||
|
||||
name === "@opencode-ai/ai/providers/google" ||
|
||||
name === "@opencode-ai/ai/providers/groq" ||
|
||||
name === "@opencode-ai/ai/providers/togetherai" ||
|
||||
name === "@opencode-ai/ai/providers/xai" ||
|
||||
name === "@opencode-ai/ai/providers/openrouter" ||
|
||||
|
||||
@@ -11,7 +11,6 @@ import { GatewayPlugin } from "./provider/gateway.js"
|
||||
import { GithubCopilotPlugin } from "./provider/github-copilot.js"
|
||||
import { GitLabPlugin } from "./provider/gitlab.js"
|
||||
import { GoogleVertexPlugin } from "./provider/google-vertex.js"
|
||||
import { GroqPlugin } from "./provider/groq.js"
|
||||
import { KiloPlugin } from "./provider/kilo.js"
|
||||
import { LLMGatewayPlugin } from "./provider/llmgateway.js"
|
||||
import { LMStudioPlugin } from "./provider/lmstudio.js"
|
||||
@@ -45,7 +44,6 @@ export const ProviderPlugins: PluginInternal.InternalPlugin[] = [
|
||||
GithubCopilotPlugin,
|
||||
GitLabPlugin,
|
||||
GoogleVertexPlugin,
|
||||
GroqPlugin,
|
||||
KiloPlugin,
|
||||
LLMGatewayPlugin,
|
||||
LMStudioPlugin,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { createProviderPlugin } from "./factory.js"
|
||||
|
||||
export const GroqPlugin = createProviderPlugin({
|
||||
id: "opencode.provider.groq",
|
||||
package: "@ai-sdk/groq",
|
||||
load: async (options) => {
|
||||
const { createGroq } = await import("@ai-sdk/groq")
|
||||
return createGroq(options)
|
||||
},
|
||||
})
|
||||
@@ -62,6 +62,7 @@ const builtins = new Map<string, () => Promise<unknown>>([
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
() => import("@opencode-ai/ai/providers/google-vertex/messages"),
|
||||
],
|
||||
["@opencode-ai/ai/providers/groq", () => import("@opencode-ai/ai/providers/groq")],
|
||||
["@opencode-ai/ai/providers/openai", () => import("@opencode-ai/ai/providers/openai")],
|
||||
["@opencode-ai/ai/providers/openai/chat", () => import("@opencode-ai/ai/providers/openai/chat")],
|
||||
["@opencode-ai/ai/providers/openai/responses", () => import("@opencode-ai/ai/providers/openai/responses")],
|
||||
|
||||
@@ -61,8 +61,8 @@ describe("AISDKNative", () => {
|
||||
})
|
||||
})
|
||||
|
||||
test("maps Cerebras, DeepInfra, and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "deepinfra", "togetherai"]) {
|
||||
test("maps Cerebras, DeepInfra, Groq, and Together AI settings, headers, and reasoning options to native providers", () => {
|
||||
for (const name of ["cerebras", "deepinfra", "groq", "togetherai"]) {
|
||||
expect(
|
||||
map(`@ai-sdk/${name}`, {
|
||||
apiKey: "secret",
|
||||
|
||||
@@ -915,6 +915,12 @@ describe("ModelResolver", () => {
|
||||
{ reasoning: { effort: "high" } },
|
||||
{ reasoning: { effort: "high" } },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/groq",
|
||||
"@opencode-ai/ai/providers/groq",
|
||||
{ reasoningEffort: "high", parallelToolCalls: false },
|
||||
{ reasoningEffort: "high", parallelToolCalls: false },
|
||||
],
|
||||
[
|
||||
"@ai-sdk/togetherai",
|
||||
"@opencode-ai/ai/providers/togetherai",
|
||||
@@ -973,6 +979,7 @@ describe("ModelResolver", () => {
|
||||
["@ai-sdk/google", "@opencode-ai/ai/providers/google", "api-model"],
|
||||
["@ai-sdk/google-vertex", "@opencode-ai/ai/providers/google-vertex", "api-model"],
|
||||
["@ai-sdk/google-vertex/anthropic", "@opencode-ai/ai/providers/google-vertex/messages", "claude-sonnet-4-6"],
|
||||
["@ai-sdk/groq", "@opencode-ai/ai/providers/groq", "api-model"],
|
||||
["@ai-sdk/openai", "@opencode-ai/ai/providers/openai", "api-model"],
|
||||
["@ai-sdk/openai-compatible", "@opencode-ai/ai/providers/openai-compatible", "api-model"],
|
||||
["@openrouter/ai-sdk-provider", "@opencode-ai/ai/providers/openrouter", "api-model"],
|
||||
@@ -1102,6 +1109,11 @@ describe("ModelResolver", () => {
|
||||
const togetherai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/togetherai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
const groq = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/groq"), {
|
||||
settings: { reasoningEffort: "high", parallelToolCalls: false },
|
||||
}),
|
||||
)
|
||||
const xai = yield* ModelResolver.fromCatalogModel(
|
||||
model(Provider.aisdk("@ai-sdk/xai"), { settings: { reasoningEffort: "high" } }),
|
||||
)
|
||||
@@ -1132,6 +1144,10 @@ describe("ModelResolver", () => {
|
||||
expect(togetherai.route.id).toBe("togetherai-chat")
|
||||
expect(togetherai.route.defaults.providerOptions).toEqual({ reasoningEffort: "high" })
|
||||
expect(String(togetherai.provider)).toBe("test-provider")
|
||||
expect(groq.route.id).toBe("groq-chat")
|
||||
expect(groq.route.protocol).toBe("groq-chat")
|
||||
expect(groq.route.defaults.providerOptions).toEqual({ reasoningEffort: "high", parallelToolCalls: false })
|
||||
expect(String(groq.provider)).toBe("test-provider")
|
||||
expect(xai.route.id).toBe("openai-responses")
|
||||
expect(xai.route.defaults.providerOptions).toEqual({
|
||||
reasoningEffort: "high",
|
||||
|
||||
@@ -7,7 +7,6 @@ import { PluginHost } from "@opencode-ai/core/plugin/host"
|
||||
import { AlibabaPlugin } from "@opencode-ai/core/plugin/provider/alibaba"
|
||||
import { CoherePlugin } from "@opencode-ai/core/plugin/provider/cohere"
|
||||
import { GatewayPlugin } from "@opencode-ai/core/plugin/provider/gateway"
|
||||
import { GroqPlugin } from "@opencode-ai/core/plugin/provider/groq"
|
||||
import { MistralPlugin } from "@opencode-ai/core/plugin/provider/mistral"
|
||||
import { PerplexityPlugin } from "@opencode-ai/core/plugin/provider/perplexity"
|
||||
import { VenicePlugin } from "@opencode-ai/core/plugin/provider/venice"
|
||||
@@ -21,7 +20,6 @@ const providers = [
|
||||
{ id: "alibaba", plugin: AlibabaPlugin, package: "@ai-sdk/alibaba", provider: "alibaba.chat" },
|
||||
{ id: "cohere", plugin: CoherePlugin, package: "@ai-sdk/cohere", provider: "cohere.chat" },
|
||||
{ id: "gateway", plugin: GatewayPlugin, package: "@ai-sdk/gateway", provider: "gateway" },
|
||||
{ id: "groq", plugin: GroqPlugin, package: "@ai-sdk/groq", provider: "groq.chat" },
|
||||
{ id: "mistral", plugin: MistralPlugin, package: "@ai-sdk/mistral", provider: "mistral.chat" },
|
||||
{ id: "perplexity", plugin: PerplexityPlugin, package: "@ai-sdk/perplexity", provider: "perplexity" },
|
||||
{ id: "venice", plugin: VenicePlugin, package: "venice-ai-sdk-provider", provider: "custom-provider.chat" },
|
||||
|
||||
@@ -12,6 +12,7 @@ describe("Provider", () => {
|
||||
"@opencode-ai/ai/providers/google-vertex/chat",
|
||||
"@opencode-ai/ai/providers/google-vertex/responses",
|
||||
"@opencode-ai/ai/providers/google-vertex/messages",
|
||||
"@opencode-ai/ai/providers/groq",
|
||||
"@opencode-ai/ai/providers/togetherai",
|
||||
]
|
||||
|
||||
|
||||
@@ -3360,7 +3360,7 @@ function executeCalls(value: unknown): ExecuteCall[] {
|
||||
|
||||
export function executeCallSummary(call: ExecuteCall) {
|
||||
const args = primitiveInputSummary(call.input ?? {}).replace(/\s+/g, " ")
|
||||
return `↳ ${call.tool}${call.status === "error" ? " (failed)" : ""}${args ? ` ${args}` : ""}`
|
||||
return `${call.tool}${args ? ` ${args}` : ""}`
|
||||
}
|
||||
|
||||
function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
|
||||
@@ -3370,11 +3370,16 @@ function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
|
||||
const [hover, setHover] = createSignal(false)
|
||||
const input = createMemo(() => Object.entries(props.call().input ?? {}))
|
||||
const expandable = createMemo(() => input().length > 0)
|
||||
const title = createMemo(() => `↳ ${props.call().tool}${props.call().status === "error" ? " (failed)" : ""}`)
|
||||
const expandedColor = createMemo(() => theme.raise(theme.text.subdued))
|
||||
const color = createMemo(() => {
|
||||
if (props.call().status === "error") return theme.text.feedback.error.default
|
||||
if (hover()) return theme.text.default
|
||||
return expanded() ? expandedColor() : theme.text.subdued
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
paddingLeft={3 + INLINE_TOOL_ICON_WIDTH}
|
||||
paddingLeft={3}
|
||||
onMouseOver={() => expandable() && setHover(true)}
|
||||
onMouseOut={() => setHover(false)}
|
||||
onMouseUp={() => {
|
||||
@@ -3382,21 +3387,16 @@ function ExecuteCallView(props: { call: Accessor<ExecuteCall> }) {
|
||||
setExpanded((value) => !value)
|
||||
}}
|
||||
>
|
||||
<text
|
||||
wrapMode="none"
|
||||
truncate
|
||||
fg={
|
||||
props.call().status === "error"
|
||||
? theme.text.feedback.error.default
|
||||
: hover()
|
||||
? theme.text.default
|
||||
: theme.text.subdued
|
||||
}
|
||||
>
|
||||
{expanded() ? title() : executeCallSummary(props.call())}
|
||||
</text>
|
||||
<box flexDirection="row">
|
||||
<box width={INLINE_TOOL_ICON_WIDTH} flexShrink={0}>
|
||||
<text fg={color()}>{props.call().status === "error" ? "✗" : "›"}</text>
|
||||
</box>
|
||||
<text flexGrow={1} wrapMode="none" truncate fg={color()}>
|
||||
{expanded() ? props.call().tool : executeCallSummary(props.call())}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={expanded()}>
|
||||
<box paddingLeft={2}>
|
||||
<box paddingLeft={1} border={["left"]} borderColor={expandedColor()}>
|
||||
<For each={input()}>
|
||||
{([key, value]) => (
|
||||
<box flexDirection="row">
|
||||
|
||||
@@ -191,13 +191,13 @@ describe("TUI inline tool wrapping", () => {
|
||||
status: "completed",
|
||||
input: { sessionID: "ses_example", notify: true },
|
||||
}),
|
||||
).toBe("↳ session.prompt [sessionID=ses_example, notify=true]")
|
||||
).toBe("session.prompt [sessionID=ses_example, notify=true]")
|
||||
expect(executeCallSummary({ tool: "session.get", status: "error", input: { nested: { hidden: true } } })).toBe(
|
||||
"↳ session.get (failed)",
|
||||
"session.get",
|
||||
)
|
||||
expect(
|
||||
executeCallSummary({ tool: "session.prompt", status: "completed", input: { text: "first line\nsecond line" } }),
|
||||
).toBe("↳ session.prompt [text=first line second line]")
|
||||
).toBe("session.prompt [text=first line second line]")
|
||||
})
|
||||
|
||||
test("summarizes generic tool arguments on one line", () => {
|
||||
|
||||
Reference in New Issue
Block a user