Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ const layer = Layer.effect(
const providers = yield* Provider.Service
const bus = yield* Bus.Service
const location = yield* Location.Service
const disableFree = ["1", "true"].includes(process.env.OPENCODE_DISABLE_FREE_MODELS?.toLowerCase() ?? "")
let input: Provider.Snapshot | undefined
const state: State.Interface<Data, Editor> = State.create<Data, Editor>({
name: "model",
Expand Down Expand Up @@ -220,6 +221,11 @@ const layer = Layer.effect(
]
}),
)
if (disableFree)
for (const providerID of [Provider.ID.opencode, Provider.ID.make("opencode-go")])
byProvider.get(providerID)?.forEach((model, id, models) => {
if (!model.cost.some((cost) => cost.input > 0)) models.delete(id)
})
const all = Array.from(byProvider.values())
.flatMap((models) => Array.from(models.values()))
.sort((left, right) => right.time.released - left.time.released)
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/plugin/provider/opencode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ function oauth(http: HttpClient.HttpClient) {
export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | ManagedPolicy.Service | Scope.Scope>({
id: "opencode.provider.opencode",
effect: Effect.fn(function* (ctx) {
const disableFree = ["1", "true"].includes(process.env.OPENCODE_DISABLE_FREE_MODELS?.toLowerCase() ?? "")
const bus = yield* Bus.Service
const client = yield* HttpClient.HttpClient
// Every request here goes to the Console, which reads the User-Agent to tell which OpenCode a member runs
Expand Down Expand Up @@ -366,7 +367,7 @@ export const OpencodePlugin = define<HttpClient.HttpClient | Bus.Service | Manag
if (!item) return
const hasKey = Boolean(process.env.OPENCODE_API_KEY || snapshot.connection || item.provider.settings?.apiKey)
providers.update(item.provider.id, (provider) => {
if (!hasKey) {
if (!hasKey && !disableFree) {
provider.activation = "enabled"
provider.settings = { ...provider.settings, apiKey: "public" }
}
Expand Down
90 changes: 90 additions & 0 deletions packages/core/test/model-free.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { expect } from "bun:test"
import { DateTime, Effect, Layer } from "effect"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { Location } from "@opencode/core/location"
import { Model } from "@opencode/core/model"
import { ModelResolver } from "@opencode/core/model-resolver"
import { Project } from "@opencode/core/project"
import { Provider } from "@opencode/core/provider"
import { AbsolutePath } from "@opencode/core/schema"
import { Session } from "@opencode/core/session"
import { SessionRunnerModel } from "@opencode/core/session/runner/model"
import { Money } from "@opencode/schema/money"
import { withEnv } from "./fixture/env"
import { location } from "./fixture/location"
import { it } from "./lib/effect"

const ref = location({ directory: AbsolutePath.make("test") })
const layer = AppNodeBuilder.build(
LayerNode.group([Provider.node, Model.node, ModelResolver.node, SessionRunnerModel.node]),
[Location.node.replace(Layer.succeed(Location.Service, Location.Service.of(ref)))],
)
const free = Model.ID.make("big-pickle")
const paid = Model.ID.make("paid")
const local = Model.ID.make("local")
const other = Provider.ID.make("other")

for (const flag of [undefined, "0", "1", "true"]) {
for (const providerID of [Provider.ID.opencode, Provider.ID.make("opencode-go")])
it.effect(`${providerID} catalog with OPENCODE_DISABLE_FREE_MODELS=${flag}`, () =>
withEnv({ OPENCODE_DISABLE_FREE_MODELS: flag }, () =>
Effect.gen(function* () {
const providers = yield* Provider.Service
const models = yield* Model.Service
const runner = yield* SessionRunnerModel.Service
const resolver = yield* ModelResolver.Service
yield* providers.transform((editor) => {
editor.update(providerID, () => {})
editor.models.update(providerID, free, (model) => {
model.time.released = 3000
model.family = Model.Family.make("gpt-luna")
model.capabilities = { ...model.capabilities, input: ["text"], output: ["text"] }
})
editor.models.update(providerID, paid, (model) => {
model.time.released = 2000
model.cost = [
{
input: Money.USDPerMillionTokens.make(1),
output: Money.USDPerMillionTokens.zero,
cache: { read: Money.USDPerMillionTokens.zero, write: Money.USDPerMillionTokens.zero },
},
]
})
editor.update(other, () => {})
editor.models.update(other, local, () => {})
})
yield* models.transform((editor) => editor.default.set(providerID, free))
const disabled = flag === "1" || flag === "true"
expect((yield* models.all()).map((model) => model.id)).toEqual(disabled ? [paid, local] : [free, paid, local])
expect(yield* models.available()).toEqual(yield* models.all())
expect((yield* models.default())?.id).toBe(disabled ? paid : free)
expect((yield* models.get(providerID, free))?.id).toBe(disabled ? undefined : free)
expect((yield* models.small(providerID))?.id).toBe(disabled ? undefined : free)
if (!disabled) return
const session = Session.Info.make({
id: Session.ID.make("ses_free_model"),
projectID: Project.ID.global,
title: "test",
model: { id: free, providerID },
cost: Money.USD.zero,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: DateTime.makeUnsafe(0), updated: DateTime.makeUnsafe(0) },
location: ref,
})
const failure = yield* runner.resolve(session, models.available).pipe(Effect.flip)
expect(failure.message).toBe(`Model unavailable: ${providerID}/big-pickle`)
expect(yield* resolver.resolve(Model.Ref.make({ providerID, id: free }))).toBeUndefined()
// Later transforms cannot restore a free model, even when all paid models are removed.
yield* models.transform((editor) => {
editor.update(providerID, free, (model) => {
model.enabled = true
})
editor.remove(providerID, paid)
})
expect((yield* models.default())?.providerID).toBe(other)
expect(yield* models.get(providerID, free)).toBeUndefined()
}).pipe(Effect.provide(layer)),
),
)
}
55 changes: 55 additions & 0 deletions packages/core/test/plugin/provider-opencode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2008,3 +2008,58 @@ describe("OpencodePlugin", () => {
),
)
})

for (const key of [undefined, "secret"]) {
testEffect(Layer.empty).effect(
`disabled free models with ${key ? "an API key" : "a Console login after startup"}`,
() =>
withEnv({ OPENCODE_DISABLE_FREE_MODELS: "true", OPENCODE_API_KEY: key }, () =>
Effect.gen(function* () {
const catalog = yield* Provider.Service
const models = yield* Model.Service
const integrations = yield* Integration.Service
const credentials = yield* Credential.Service
yield* integrations.transform((editor) =>
editor.method.update({
integrationID: Integration.ID.make("opencode"),
method: { type: "env", names: ["OPENCODE_API_KEY"] },
}),
)
yield* catalog.transform((editor) => {
editor.update(Provider.ID.opencode, () => {})
editor.models.update(Provider.ID.opencode, Model.ID.make("free"), (model) => {
model.cost = cost(0)
})
editor.models.update(Provider.ID.opencode, Model.ID.make("paid"), (model) => {
model.cost = cost(1)
})
})
yield* addPlugin().pipe(Effect.provideService(HttpClient.HttpClient, noRemoteConfig))
if (!key) {
expect(yield* catalog.available()).toEqual([])
expect(yield* models.all()).toEqual([])
expect(yield* models.default()).toBeUndefined()
expect((yield* integrations.get(Integration.ID.make("opencode")))?.methods.length).toBeGreaterThan(0)
yield* credentials.create({
integrationID: Integration.ID.make("opencode"),
value: Credential.OAuth.make({
type: "oauth",
methodID: Integration.MethodID.make("device"),
access: "access",
refresh: "refresh",
expires: Date.now() + 600_000,
}),
})
}
const available = yield* eventually(models.available(), (models) =>
models.some((model) => model.providerID === Provider.ID.opencode),
)
expect((yield* catalog.available()).map((provider) => provider.id)).toContain(Provider.ID.opencode)
expect(available.map((model) => model.id)).toEqual([Model.ID.make("paid")])
expect(yield* models.all()).toEqual(available)
expect((yield* models.default())?.id).toBe(Model.ID.make("paid"))
expect(yield* models.get(Provider.ID.opencode, Model.ID.make("free"))).toBeUndefined()
}).pipe(Effect.provide(PluginTestLayer)),
),
)
}
2 changes: 2 additions & 0 deletions services/www/src/docs/content/console/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,8 @@ models to generate session titles.
- LongCat 2.5 Preview Free is free on OpenCode for a limited time. Its provider follows a zero-retention policy and does not use your data for model training.
- Jev 1.13 Free is available on OpenCode for a limited time.

To hide free models in OpenCode, set `OPENCODE_DISABLE_FREE_MODELS=1`. See [free models](/models#free-models).

[Contact us](mailto:help@anoma.ly) if you have any questions.

### Auto-recharge and budgets
Expand Down
18 changes: 18 additions & 0 deletions services/www/src/docs/content/models.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,24 @@ falls back to the newest available supported model.

See [Config](/config) for configuration locations and precedence.

## Free models

Set `OPENCODE_DISABLE_FREE_MODELS=1` (or `true`) on the server process to hide [free OpenCode models](/console/models#free-models):

```bash
OPENCODE_DISABLE_FREE_MODELS=1 opencode serve
```

For the shared background server:

```bash
opencode service set env OPENCODE_DISABLE_FREE_MODELS 1
```

Free means an `opencode` or `opencode-go` model with no input price. These models disappear from the catalog and defaults;
explicitly selecting one fails with `Model unavailable`. Signed out, the `opencode` provider is absent until you connect
an OpenCode Console account or set `OPENCODE_API_KEY`, which restores its paid models.

## Variants

Variants are named options for one model, often used for reasoning effort or token budgets. Add `#variant` when selecting
Expand Down
Loading