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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: CI

on:
push:
pull_request:
workflow_dispatch:

permissions:
contents: read

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: erlef/setup-beam@v1
with:
elixir-version: "1.18"
otp-version: "27"
- run: mix deps.get
- run: npm install --global opencode-ai@1
- run: mix compile --warnings-as-errors
- run: mix test --warnings-as-errors
- run: MIX_ENV=prod mix compile --warnings-as-errors
35 changes: 31 additions & 4 deletions .github/workflows/sync-upstream.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ on:
- cron: "0 6 * * *"
workflow_dispatch: # Allow manual trigger

concurrency:
group: sync-upstream
cancel-in-progress: false

jobs:
sync:
runs-on: ubuntu-latest
Expand All @@ -18,8 +22,20 @@ jobs:

- name: Fetch upstream OpenAPI spec
run: |
curl -sSL "https://raw.githubusercontent.com/anomalyco/opencode/dev/packages/sdk/openapi.json" \
-o priv/opencode_openapi.json
curl --fail --silent --show-error --location --retry 3 --connect-timeout 10 --max-time 60 \
"https://raw.githubusercontent.com/anomalyco/opencode/v1/packages/sdk/openapi.json" \
-o "$RUNNER_TEMP/opencode_openapi.json"
python3 - "$RUNNER_TEMP/opencode_openapi.json" <<'PY'
import json
import sys

with open(sys.argv[1]) as document:
spec = json.load(document)
assert spec.get("openapi", "").startswith("3."), "Expected an OpenAPI 3 document"
for path in ("/global/health", "/session", "/event"):
assert path in spec.get("paths", {}), f"Missing supported v1 route: {path}"
PY
mv "$RUNNER_TEMP/opencode_openapi.json" priv/opencode_openapi.json

- name: Check for changes
id: changes
Expand All @@ -34,8 +50,8 @@ jobs:
if: steps.changes.outputs.changed == 'true'
uses: erlef/setup-beam@v1
with:
elixir-version: "1.15"
otp-version: "26"
elixir-version: "1.18"
otp-version: "27"

- name: Install dependencies
if: steps.changes.outputs.changed == 'true'
Expand All @@ -45,6 +61,17 @@ jobs:
if: steps.changes.outputs.changed == 'true'
run: mix opencode.gen.client --spec priv/opencode_openapi.json

- name: Install OpenCode
if: steps.changes.outputs.changed == 'true'
run: npm install --global opencode-ai@1

- name: Validate SDK before release
if: steps.changes.outputs.changed == 'true'
run: |
mix compile --warnings-as-errors
mix test --warnings-as-errors
MIX_ENV=prod mix compile --warnings-as-errors

- name: Bump patch version
if: steps.changes.outputs.changed == 'true'
id: version
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -378,12 +378,20 @@ API functions and types are documented in generated module docs, primarily:
## Regenerating

The OpenAPI spec is at `priv/opencode_openapi.json`.
This SDK targets OpenCode's v1 API. The sync workflow follows
[`v1/packages/sdk/openapi.json`](https://github.com/anomalyco/opencode/blob/v1/packages/sdk/openapi.json).
Upstream `dev` now uses a different protocol API; its `packages/protocol/openapi.json`
is not a compatible replacement. Generation requires Elixir 1.18 or later.
Regenerate the client with:

```bash
mix opencode.gen.client --spec priv/opencode_openapi.json
```

Run `mix test` to check the SDK. Server integration tests require the `opencode`
executable on `PATH`; CI uses the latest published OpenCode 1.x release and checks compilation and tests
before publishing an upstream sync.

## Note

This project is unofficial and is not affiliated with the OpenCode team.
12 changes: 12 additions & 0 deletions lib/mix/tasks/opencode.gen.client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ defmodule Mix.Tasks.Opencode.Gen.Client do
""")
end

validate_spec!(spec_path)

Mix.shell().info("🔧 Generating OpenCode client from: #{spec_path}")
Mix.shell().info("")

Expand All @@ -76,6 +78,16 @@ defmodule Mix.Tasks.Opencode.Gen.Client do
:ok
end

defp validate_spec!(path) do
with {:ok, spec} <- Jason.decode(File.read!(path)),
%{"openapi" => "3." <> _, "paths" => paths} when is_map(paths) <- spec,
true <- map_size(paths) > 0 do
:ok
else
_ -> Mix.raise("Invalid OpenAPI spec at #{path}: expected an OpenAPI 3 JSON document with paths")
end
end

defp fix_missing_types do
@generated_dir
|> Path.join("*.ex")
Expand Down
94 changes: 66 additions & 28 deletions lib/opencode/client.ex
Original file line number Diff line number Diff line change
Expand Up @@ -79,40 +79,72 @@ defmodule OpenCode.Client do
Stream.resource(
fn ->
ref = make_ref()
pid = spawn_link(fn -> sse_run(req, self(), ref) end)
%{ref: ref, pid: pid, buf: ""}
consumer = self()
pid = spawn_link(fn -> sse_run(req, consumer, ref) end)
Comment thread
UtkarshUsername marked this conversation as resolved.
Comment thread
UtkarshUsername marked this conversation as resolved.
%{ref: ref, pid: pid, buf: "", done?: false}
end,
fn state ->
receive do
{ref, :data, data} when ref == state.ref ->
{events, buf} = parse_sse(state.buf <> data)
{events, %{state | buf: buf}}

{ref, :done} when ref == state.ref ->
{:halt, state}

{ref, :error, error} when ref == state.ref ->
{[{:error, error}], state}
end
fn
%{done?: true} = state ->
{:halt, state}

state ->
receive do
{ref, :data, data} when ref == state.ref ->
{events, buf} = parse_sse(state.buf <> data)
{events, %{state | buf: buf}}

{ref, :done} when ref == state.ref ->
{:halt, state}

{ref, :error, error} when ref == state.ref ->
{[{:error, error}], %{state | done?: true}}
end
end,
fn state ->
case Process.alive?(state.pid) do
true -> Process.exit(state.pid, :normal)
false -> :ok
end
end
&sse_stop/1
)
end

defp sse_stop(%{pid: pid, ref: ref}) do
Process.unlink(pid)
monitor = Process.monitor(pid)
Process.exit(pid, :kill)

receive do
{:DOWN, ^monitor, :process, ^pid, _reason} -> :ok
end

drain_sse(ref)
end

defp drain_sse(ref) do
receive do
{^ref, kind, _data} when kind in [:data, :error] -> drain_sse(ref)
{^ref, :done} -> drain_sse(ref)
after
0 -> :ok
end
end

defp sse_run(req, pid, ref) do
case Req.request(req,
into: fn {:data, data}, acc ->
send(pid, {ref, :data, data})
{:cont, acc}
retry: false,
into: fn {:data, data}, {request, response} ->
if response.status in 200..299 do
send(pid, {ref, :data, data})
{:cont, {request, response}}
else
{:cont, {request, %{response | body: response.body <> data}}}
end
end
) do
{:ok, _} -> send(pid, {ref, :done})
{:error, error} -> send(pid, {ref, :error, error})
{:ok, %{status: status}} when status in 200..299 ->
send(pid, {ref, :done})

{:ok, %{status: status, body: body}} ->
send(pid, {ref, :error, {status, body}})

{:error, error} ->
send(pid, {ref, :error, error})
end
end

Expand All @@ -121,8 +153,12 @@ defmodule OpenCode.Client do
count = length(parts)

case count do
0 -> {[], ""}
1 -> {[], data}
0 ->
{[], ""}

1 ->
{[], data}

_ ->
events = parts |> Enum.drop(-1) |> Enum.flat_map(&decode_sse/1)
{events, List.last(parts)}
Expand All @@ -139,7 +175,9 @@ defmodule OpenCode.Client do
|> String.trim()

case data do
"" -> []
"" ->
[]

_ ->
case Jason.decode(data) do
{:ok, val} -> [val]
Expand Down
55 changes: 54 additions & 1 deletion priv/opencode_openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -20821,6 +20821,59 @@
"reasoning": {
"type": "boolean"
},
"reasoning_options": {
"type": "array",
"items": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["effort"]
},
"values": {
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["type", "values"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["toggle"]
}
},
"required": ["type"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["budget_tokens"]
},
"min": {
"type": "number"
},
"max": {
"type": "number"
}
},
"required": ["type"],
"additionalProperties": false
}
]
},
"description": "Reasoning controls the model accepts, used to build its variants"
},
"temperature": {
"type": "boolean"
},
Expand Down Expand Up @@ -21263,7 +21316,7 @@
"enum": ["notify"]
}
],
"description": "Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications"
"description": "Check for new versions and show an update notice. Updates are never installed automatically; run /update or `opencode upgrade`. Set to false to disable update checks. true and 'notify' behave the same"
},
"disabled_providers": {
"type": "array",
Expand Down
Loading
Loading