diff --git a/adapters/models/openai_compatible_local.go b/adapters/models/openai_compatible_local.go index e02421e..820c57a 100644 --- a/adapters/models/openai_compatible_local.go +++ b/adapters/models/openai_compatible_local.go @@ -25,6 +25,13 @@ type OpenAICompatibleLocalAdapter struct { apiKey string } +// compatibleDefaultContextBytes is a conservative bound used when an +// OpenAI-compatible local endpoint omits n_ctx from /v1/models. The adapter +// is a chat-completions adapter, so advisory capability is known from the +// adapter contract; quality and activation still require governed +// qualification/evaluation in the capability registry. +const compatibleDefaultContextBytes int64 = 32 << 10 + func NewOpenAICompatibleLocalAdapter(raw string, transport http.RoundTripper) (*OpenAICompatibleLocalAdapter, error) { return NewOpenAICompatibleLocalAdapterWithAPIKey(raw, "", transport) } @@ -90,7 +97,10 @@ func (a *OpenAICompatibleLocalAdapter) Discover(ctx context.Context) ([]Discover stableMetadata := fmt.Sprintf("%s\x00%s\x00%d\x00%d\x00%d\x00%d\x00%s", item.ID, item.OwnedBy, item.Meta.Context, item.Meta.TrainingContext, item.Meta.Parameters, item.Meta.Size, item.Meta.FileType) digest := sha256.Sum256([]byte(stableMetadata)) contextBytes := item.Meta.Context * 4 - models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityUnknown}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType}) + if contextBytes <= 0 { + contextBytes = compatibleDefaultContextBytes + } + models = append(models, DiscoveredModel{ProviderModelID: capability.ProviderModelID(item.ID), Fingerprint: capability.ModelFingerprint(hex.EncodeToString(digest[:])), MaxContextBytes: contextBytes, Modalities: []capability.ModelModality{capability.ModalityText}, Capabilities: []capability.ModelCapability{capability.CapabilityAdvisory}, SizeBytes: item.Meta.Size, Runtime: item.OwnedBy, Quantization: item.Meta.FileType}) } return models, ProviderProbe{Health: capability.HealthAvailable} } diff --git a/adapters/models/openai_compatible_local_test.go b/adapters/models/openai_compatible_local_test.go index 39ab6cc..fceedd5 100644 --- a/adapters/models/openai_compatible_local_test.go +++ b/adapters/models/openai_compatible_local_test.go @@ -18,6 +18,34 @@ type captureRoundTripper struct { body []byte } +type modelsMetadataRoundTripper struct{} + +func (modelsMetadataRoundTripper) RoundTrip(request *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"medium-model","owned_by":"llama.cpp","meta":{}}]}`)), + Header: make(http.Header), + Request: request, + }, nil +} + +func TestOpenAICompatibleLocalAdapterNormalizesMinimalModelMetadata(t *testing.T) { + adapter, err := NewOpenAICompatibleLocalAdapter("http://gateway/v1", modelsMetadataRoundTripper{}) + if err != nil { + t.Fatal(err) + } + models, probe := adapter.Discover(context.Background()) + if probe.Health != capability.HealthAvailable || len(models) != 1 { + t.Fatalf("unexpected discovery result: probe=%+v models=%+v", probe, models) + } + if models[0].MaxContextBytes != compatibleDefaultContextBytes { + t.Fatalf("context bound=%d, want %d", models[0].MaxContextBytes, compatibleDefaultContextBytes) + } + if len(models[0].Capabilities) != 1 || models[0].Capabilities[0] != capability.CapabilityAdvisory { + t.Fatalf("capabilities=%v, want advisory", models[0].Capabilities) + } +} + func TestOpenAICompatibleLocalAdapterUsesConfiguredTimeout(t *testing.T) { adapter, err := NewOpenAICompatibleLocalAdapterWithAPIKeyAndTimeout("http://gateway/v1", "", &captureRoundTripper{}, 47*time.Second) if err != nil {