Skip to content

Commit 3f8788d

Browse files
authored
Merge pull request #51 from LAA-Software-Engineering/issue/17-models-registry
feat(models): ModelClient registry, mock, and OpenAI client (issue #17)
2 parents c82cfdb + 2e4d308 commit 3f8788d

7 files changed

Lines changed: 410 additions & 0 deletions

File tree

internal/models/doc.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,5 @@
11
// Package models abstracts model providers and client interfaces.
2+
//
3+
// [Registry] resolves namespace/model_id strings using Project.spec.providers.models.
4+
// Use [MockClient] for deterministic tests; [OpenAIClient] is the MVP OpenAI-compatible backend (§12.2 F).
25
package models

internal/models/key.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package models
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"strings"
7+
)
8+
9+
// ResolveAPIKeyFrom parses apiKeyFrom values from project YAML (§7.1), e.g. "env:OPENAI_API_KEY".
10+
func ResolveAPIKeyFrom(spec string) (string, error) {
11+
spec = strings.TrimSpace(spec)
12+
if spec == "" {
13+
return "", fmt.Errorf("models: empty apiKeyFrom")
14+
}
15+
if strings.HasPrefix(spec, "env:") {
16+
name := strings.TrimSpace(strings.TrimPrefix(spec, "env:"))
17+
if name == "" {
18+
return "", fmt.Errorf("models: apiKeyFrom env: requires a variable name")
19+
}
20+
v := os.Getenv(name)
21+
if v == "" {
22+
return "", fmt.Errorf("models: environment variable %q is not set", name)
23+
}
24+
return v, nil
25+
}
26+
return "", fmt.Errorf("models: unsupported apiKeyFrom %q (MVP: env:VAR only)", spec)
27+
}

internal/models/mock.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package models
2+
3+
import "context"
4+
5+
// MockClient returns a fixed response for tests and offline agent steps (design doc §12.2 F MVP).
6+
type MockClient struct {
7+
// Content is returned as GenerateResponse.Content verbatim (often JSON for structured output tests).
8+
Content string
9+
Meta *GenerateMeta
10+
}
11+
12+
// Generate returns deterministic output without calling the network.
13+
func (m *MockClient) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) {
14+
_ = ctx
15+
_ = req
16+
meta := GenerateMeta{DurationMs: 1, CostUSD: 0.001}
17+
if m.Meta != nil {
18+
meta = *m.Meta
19+
}
20+
return GenerateResponse{Content: m.Content, Meta: meta}, nil
21+
}

internal/models/models_test.go

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package models
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"testing"
10+
11+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
12+
)
13+
14+
// Agent-step style: mock returns JSON that unmarshals into a fixed schema (issue #17 acceptance).
15+
func TestMockClient_usableForAgentStructuredOutput(t *testing.T) {
16+
ctx := context.Background()
17+
cli := &MockClient{
18+
Content: `{"summary":"done","findings":[{"id":"f1"}]}`,
19+
Meta: &GenerateMeta{DurationMs: 42, CostUSD: 0.02},
20+
}
21+
resp, err := cli.Generate(ctx, GenerateRequest{
22+
Model: "mock/test",
23+
Messages: []ChatMessage{{Role: "user", Content: "run"}},
24+
})
25+
if err != nil {
26+
t.Fatal(err)
27+
}
28+
var decoded struct {
29+
Summary string `json:"summary"`
30+
Findings []struct {
31+
ID string `json:"id"`
32+
} `json:"findings"`
33+
}
34+
if err := json.Unmarshal([]byte(resp.Content), &decoded); err != nil {
35+
t.Fatalf("decode mock output: %v", err)
36+
}
37+
if decoded.Summary != "done" || len(decoded.Findings) != 1 || decoded.Findings[0].ID != "f1" {
38+
t.Fatalf("got %+v", decoded)
39+
}
40+
if resp.Meta.DurationMs != 42 || resp.Meta.CostUSD != 0.02 {
41+
t.Fatalf("meta %+v", resp.Meta)
42+
}
43+
}
44+
45+
func TestRegistry_unknownProviderNamespace(t *testing.T) {
46+
g := &spec.ProjectGraph{
47+
Spec: spec.ProjectSpec{
48+
Providers: &spec.ProjectProviders{
49+
Models: map[string]spec.ModelProviderConfig{
50+
"openai": {Type: "openai", APIKeyFrom: "env:OPENAI_API_KEY"},
51+
},
52+
},
53+
},
54+
}
55+
reg := NewRegistry(g)
56+
_, _, err := reg.ClientFor("anthropic/claude-3")
57+
if err == nil {
58+
t.Fatal("expected error")
59+
}
60+
if !strings.Contains(err.Error(), "unknown provider namespace") || !strings.Contains(err.Error(), "anthropic") {
61+
t.Fatalf("got %v", err)
62+
}
63+
}
64+
65+
func TestRegistry_modelRefFormat(t *testing.T) {
66+
g := &spec.ProjectGraph{
67+
Spec: spec.ProjectSpec{
68+
Providers: &spec.ProjectProviders{
69+
Models: map[string]spec.ModelProviderConfig{
70+
"openai": {Type: "openai", APIKeyFrom: "env:OPENAI_API_KEY"},
71+
},
72+
},
73+
},
74+
}
75+
reg := NewRegistry(g)
76+
_, _, err := reg.ClientFor("badref")
77+
if err == nil {
78+
t.Fatal("expected error")
79+
}
80+
if !strings.Contains(err.Error(), "namespace/model_id") {
81+
t.Fatalf("got %v", err)
82+
}
83+
}
84+
85+
func TestRegistry_resolvesOpenAIAndModelID(t *testing.T) {
86+
t.Setenv("OPENAI_API_KEY", "sk-test")
87+
g := &spec.ProjectGraph{
88+
Spec: spec.ProjectSpec{
89+
Providers: &spec.ProjectProviders{
90+
Models: map[string]spec.ModelProviderConfig{
91+
"openai": {Type: "openai", APIKeyFrom: "env:OPENAI_API_KEY"},
92+
},
93+
},
94+
},
95+
}
96+
reg := NewRegistry(g)
97+
cli, id, err := reg.ClientFor("openai/gpt-4.1")
98+
if err != nil {
99+
t.Fatal(err)
100+
}
101+
if id != "gpt-4.1" {
102+
t.Fatalf("model id %q", id)
103+
}
104+
if _, ok := cli.(*OpenAIClient); !ok {
105+
t.Fatalf("want *OpenAIClient, got %T", cli)
106+
}
107+
}
108+
109+
func TestOpenAIClient_Generate_usesChatCompletions(t *testing.T) {
110+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
111+
if r.URL.Path != "/v1/chat/completions" {
112+
t.Errorf("path %s", r.URL.Path)
113+
http.NotFound(w, r)
114+
return
115+
}
116+
auth := r.Header.Get("Authorization")
117+
if auth != "Bearer sk-mock" {
118+
t.Errorf("Authorization %q", auth)
119+
}
120+
w.Header().Set("Content-Type", "application/json")
121+
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"hello"}}]}`))
122+
}))
123+
defer srv.Close()
124+
125+
c := &OpenAIClient{APIKey: "sk-mock", BaseURL: srv.URL + "/v1", HTTPClient: srv.Client()}
126+
resp, err := c.Generate(context.Background(), GenerateRequest{
127+
Model: "gpt-4.1",
128+
Messages: []ChatMessage{
129+
{Role: "user", Content: "hi"},
130+
},
131+
})
132+
if err != nil {
133+
t.Fatal(err)
134+
}
135+
if resp.Content != "hello" {
136+
t.Fatalf("content %q", resp.Content)
137+
}
138+
}
139+
140+
func TestResolveAPIKeyFrom_env(t *testing.T) {
141+
t.Setenv("MY_KEY", "abc")
142+
got, err := ResolveAPIKeyFrom("env:MY_KEY")
143+
if err != nil || got != "abc" {
144+
t.Fatalf("%q %v", got, err)
145+
}
146+
_, err = ResolveAPIKeyFrom("env:MISSING_XYZ_404")
147+
if err == nil {
148+
t.Fatal("expected error")
149+
}
150+
}

internal/models/openai.go

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package models
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"strings"
11+
"time"
12+
13+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
14+
)
15+
16+
const defaultOpenAIBase = "https://api.openai.com/v1"
17+
18+
// OpenAIClient is a minimal OpenAI-compatible chat client (design doc §12.2 F MVP).
19+
type OpenAIClient struct {
20+
APIKey string
21+
BaseURL string
22+
HTTPClient *http.Client
23+
}
24+
25+
// NewOpenAIClientFromConfig builds a client using apiKeyFrom (e.g. env:OPENAI_API_KEY) from project YAML.
26+
func NewOpenAIClientFromConfig(cfg spec.ModelProviderConfig) (*OpenAIClient, error) {
27+
key, err := ResolveAPIKeyFrom(cfg.APIKeyFrom)
28+
if err != nil {
29+
return nil, err
30+
}
31+
return &OpenAIClient{APIKey: key, BaseURL: defaultOpenAIBase, HTTPClient: http.DefaultClient}, nil
32+
}
33+
34+
func (c *OpenAIClient) base() string {
35+
if c != nil && strings.TrimSpace(c.BaseURL) != "" {
36+
return strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
37+
}
38+
return defaultOpenAIBase
39+
}
40+
41+
func (c *OpenAIClient) http() *http.Client {
42+
if c != nil && c.HTTPClient != nil {
43+
return c.HTTPClient
44+
}
45+
return http.DefaultClient
46+
}
47+
48+
// Generate calls POST /v1/chat/completions on the configured base URL.
49+
func (c *OpenAIClient) Generate(ctx context.Context, req GenerateRequest) (GenerateResponse, error) {
50+
if c == nil || c.APIKey == "" {
51+
return GenerateResponse{}, fmt.Errorf("models: openai client not configured")
52+
}
53+
start := time.Now()
54+
55+
type msg struct {
56+
Role string `json:"role"`
57+
Content string `json:"content"`
58+
}
59+
payload := struct {
60+
Model string `json:"model"`
61+
Messages []msg `json:"messages"`
62+
}{
63+
Model: req.Model,
64+
}
65+
for _, m := range req.Messages {
66+
payload.Messages = append(payload.Messages, msg{Role: m.Role, Content: m.Content})
67+
}
68+
body, err := json.Marshal(payload)
69+
if err != nil {
70+
return GenerateResponse{}, err
71+
}
72+
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.base()+"/chat/completions", bytes.NewReader(body))
73+
if err != nil {
74+
return GenerateResponse{}, err
75+
}
76+
httpReq.Header.Set("Content-Type", "application/json")
77+
httpReq.Header.Set("Authorization", "Bearer "+c.APIKey)
78+
resp, err := c.http().Do(httpReq)
79+
if err != nil {
80+
return GenerateResponse{}, err
81+
}
82+
defer resp.Body.Close()
83+
b, err := io.ReadAll(resp.Body)
84+
if err != nil {
85+
return GenerateResponse{}, err
86+
}
87+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
88+
return GenerateResponse{}, fmt.Errorf("models: openai HTTP %d: %s", resp.StatusCode, truncateErrBody(b))
89+
}
90+
var out struct {
91+
Choices []struct {
92+
Message struct {
93+
Content string `json:"content"`
94+
} `json:"message"`
95+
} `json:"choices"`
96+
}
97+
if err := json.Unmarshal(b, &out); err != nil {
98+
return GenerateResponse{}, fmt.Errorf("models: decode openai response: %w", err)
99+
}
100+
if len(out.Choices) == 0 {
101+
return GenerateResponse{}, fmt.Errorf("models: openai returned no choices")
102+
}
103+
return GenerateResponse{
104+
Content: out.Choices[0].Message.Content,
105+
Meta: GenerateMeta{DurationMs: time.Since(start).Milliseconds(), CostUSD: 0},
106+
}, nil
107+
}
108+
109+
func truncateErrBody(b []byte) string {
110+
const n = 500
111+
s := string(b)
112+
if len(s) <= n {
113+
return s
114+
}
115+
return s[:n] + "..."
116+
}

internal/models/registry.go

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
package models
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/LAA-Software-Engineering/agentic-control-plane/internal/spec"
8+
)
9+
10+
// Registry resolves model references using Project.spec.providers.models (design doc §7.1, issue #17).
11+
type Registry struct {
12+
models map[string]spec.ModelProviderConfig
13+
}
14+
15+
// NewRegistry returns a registry from the merged project graph.
16+
func NewRegistry(g *spec.ProjectGraph) *Registry {
17+
var m map[string]spec.ModelProviderConfig
18+
if g != nil && g.Spec.Providers != nil && g.Spec.Providers.Models != nil {
19+
m = g.Spec.Providers.Models
20+
}
21+
return &Registry{models: m}
22+
}
23+
24+
// ClientFor resolves modelRef in the form "namespace/model_id" (e.g. "openai/gpt-4.1").
25+
// The returned modelID is the segment after the first slash and should be passed as GenerateRequest.Model.
26+
func (r *Registry) ClientFor(modelRef string) (client ModelClient, modelID string, err error) {
27+
modelRef = strings.TrimSpace(modelRef)
28+
if modelRef == "" {
29+
return nil, "", fmt.Errorf("models: empty model reference")
30+
}
31+
i := strings.IndexByte(modelRef, '/')
32+
if i <= 0 || i == len(modelRef)-1 {
33+
return nil, "", fmt.Errorf("models: model %q must be namespace/model_id", modelRef)
34+
}
35+
ns := modelRef[:i]
36+
id := modelRef[i+1:]
37+
if r == nil || r.models == nil {
38+
return nil, "", fmt.Errorf("models: unknown provider namespace %q", ns)
39+
}
40+
cfg, ok := r.models[ns]
41+
if !ok {
42+
return nil, "", fmt.Errorf("models: unknown provider namespace %q", ns)
43+
}
44+
45+
switch strings.ToLower(strings.TrimSpace(cfg.Type)) {
46+
case "openai":
47+
cl, err := NewOpenAIClientFromConfig(cfg)
48+
if err != nil {
49+
return nil, "", err
50+
}
51+
return cl, id, nil
52+
case "mock":
53+
return &MockClient{
54+
Content: `{"summary":"mock","findings":[]}`,
55+
Meta: &GenerateMeta{DurationMs: 1, CostUSD: 0},
56+
}, id, nil
57+
default:
58+
return nil, "", fmt.Errorf("models: unsupported provider type %q for namespace %q", cfg.Type, ns)
59+
}
60+
}

0 commit comments

Comments
 (0)