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
19 changes: 19 additions & 0 deletions .github/workflows/native-integration.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: Native Integration

on:
workflow_dispatch:

permissions:
contents: read

jobs:
native-integration:
name: native-integration
runs-on: [self-hosted, macOS, ARM64, fmgo-native]
steps:
- uses: actions/checkout@v5
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: false
- run: make integration
3 changes: 1 addition & 2 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,4 @@ jobs:
with:
go-version-file: go.mod
cache: false
- run: go test ./...
- run: go vet ./...
- run: make check
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
examples/
6 changes: 3 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,10 @@ generic AI abstraction.
## Tests and Documentation

- Use `WithExecutable` and the existing fake executable pattern for process
tests. Do not require a downloaded Foundation Model or run live prompts.
tests. Native tests run only with `FMGO_INTEGRATION=1` on a prepared machine.
- Run `go fmt ./...`, `go vet ./...`, and `go test ./...` after Go changes.
- The full suite targets macOS 27 or later because the public constructor
validates platform support.
- Use `make integration` only on macOS 27+ Apple Silicon with the fm license
accepted and the system model available.
- Update README, GoDoc, examples, and tests whenever a public contract changes.

## Workflow
Expand Down
15 changes: 10 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,14 +39,19 @@ refactor/process-cleanup
Run these commands before opening a pull request:

```bash
go fmt ./...
go vet ./...
go test ./...
make check
```

The test suite uses a fake `fm` executable and does not require a Foundation
Model download. fmgo supports macOS 27 or later, so run the full suite on a
supported macOS version.
Model download.

Native integration tests require macOS 27+ on Apple Silicon, the Foundation
Models CLI license accepted, and the system model available. Run them only on
a prepared machine:

```bash
make integration
```

## Commits

Expand Down
29 changes: 29 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
GO ?= go

.PHONY: help test integration vet examples check

help:
@printf '%s\n' \
'make test Run the Go test suite.' \
'make integration Run native Foundation Models integration tests.' \
'make vet Run go vet.' \
'make examples Build integration examples into examples/.' \
'make check Run tests, vet, and build examples.'

test:
$(GO) test ./...

integration:
FMGO_INTEGRATION=1 $(GO) test -count=1 ./...

vet:
$(GO) vet ./...

examples:
mkdir -p examples
$(GO) build -o examples/respond ./cmd/respond
$(GO) build -o examples/server ./cmd/server
$(GO) build -o examples/stream ./cmd/stream
$(GO) build -o examples/structured ./cmd/structured

check: test vet examples
35 changes: 35 additions & 0 deletions respond.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"os"
)

// Request configures a Foundation Models response.
Expand Down Expand Up @@ -34,6 +35,12 @@ type Response struct {

// Respond generates a complete non-streaming response.
func (c *Client) Respond(ctx context.Context, request Request) (Response, error) {
request, cleanup, err := request.withSchemaFile()
if err != nil {
return Response{}, err
}
defer cleanup()

args, err := request.args(false)
if err != nil {
return Response{}, err
Expand All @@ -45,6 +52,34 @@ func (c *Client) Respond(ctx context.Context, request Request) (Response, error)
return Response{Text: string(stdout)}, nil
}

func (r Request) withSchemaFile() (Request, func(), error) {
if len(r.Schema) == 0 {
return r, func() {}, nil
}
if r.SchemaFile != "" {
return Request{}, nil, fmt.Errorf("fmgo: schema and schema file cannot both be set")
}

file, err := os.CreateTemp("", "fmgo-schema-*.json")
if err != nil {
return Request{}, nil, fmt.Errorf("fmgo: create schema file: %w", err)
}
path := file.Name()
if _, err := file.Write(r.Schema); err != nil {
_ = file.Close()
_ = os.Remove(path)
return Request{}, nil, fmt.Errorf("fmgo: write schema file: %w", err)
}
if err := file.Close(); err != nil {
_ = os.Remove(path)
return Request{}, nil, fmt.Errorf("fmgo: close schema file: %w", err)
}

r.Schema = nil
r.SchemaFile = path
return r, func() { _ = os.Remove(path) }, nil
}

// RespondAs generates JSON for T and decodes it into T.
func RespondAs[T any](ctx context.Context, client *Client, request Request) (T, error) {
var result T
Expand Down
12 changes: 11 additions & 1 deletion schema.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,9 @@ func SchemaFrom(value any) (Schema, error) {
if err != nil {
return nil, err
}
if typeOf.Kind() == reflect.Struct && typeOf.Name() != "" {
schema["title"] = typeOf.Name()
}
encoded, err := json.Marshal(schema)
if err != nil {
return nil, fmt.Errorf("fmgo: encode schema: %w", err)
Expand Down Expand Up @@ -162,6 +165,7 @@ func schemaForStruct(typeOf reflect.Type, visiting map[reflect.Type]bool) (map[s

properties := map[string]any{}
required := []string{}
order := []string{}
for index := range typeOf.NumField() {
field := typeOf.Field(index)
if !field.IsExported() || field.Anonymous {
Expand All @@ -176,11 +180,17 @@ func schemaForStruct(typeOf reflect.Type, visiting map[reflect.Type]bool) (map[s
return nil, err
}
properties[name] = property
order = append(order, name)
if !optional {
required = append(required, name)
}
}
schema := map[string]any{"type": "object", "properties": properties}
schema := map[string]any{
"type": "object",
"x-order": order,
"properties": properties,
"additionalProperties": false,
}
if len(required) != 0 {
schema["required"] = required
}
Expand Down
16 changes: 16 additions & 0 deletions stream.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,29 @@ type Stream struct {
text string
err error
once sync.Once
cleanup func()
clean sync.Once
}

// Stream starts a streaming response.
func (c *Client) Stream(ctx context.Context, request Request) (*Stream, error) {
request, cleanup, err := request.withSchemaFile()
if err != nil {
return nil, err
}
args, err := request.args(true)
if err != nil {
cleanup()
return nil, err
}
path, err := c.executablePath()
if err != nil {
cleanup()
return nil, err
}
command, err := internalprocess.StartPiped(ctx, path, args...)
if err != nil {
cleanup()
return nil, commandError(args, "", err)
}
stderr := make(chan []byte, 1)
Expand All @@ -45,6 +54,7 @@ func (c *Client) Stream(ctx context.Context, request Request) (*Stream, error) {
stdout: command.Stdout(),
stderr: stderr,
buffer: make([]byte, 32*1024),
cleanup: cleanup,
}, nil
}

Expand Down Expand Up @@ -72,6 +82,7 @@ func (s *Stream) Next() bool {
if waitErr := s.command.Wait(); waitErr != nil {
s.err = commandError([]string{"respond", "--stream"}, string(stderr), waitErr)
}
s.cleanupSchemaFile()
return false
}

Expand All @@ -87,6 +98,11 @@ func (s *Stream) Close() error {
s.once.Do(func() {
_ = s.stdout.Close()
closeErr = s.command.Close()
s.cleanupSchemaFile()
})
return closeErr
}

func (s *Stream) cleanupSchemaFile() {
s.clean.Do(s.cleanup)
}
Loading