mirror of
https://github.com/lukaszraczylo/harness-sync.git
synced 2026-10-12 00:45:22 +00:00
249 lines
6.9 KiB
Go
249 lines
6.9 KiB
Go
// Package common provides shared helpers for harness adapters.
|
|
package common
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/lukaszraczylo/harness-sync/internal/canonical"
|
|
)
|
|
|
|
const fallbackGatewayProviderID = "harness-sync-gateway"
|
|
|
|
// GatewayProviderKey returns a stable provider key derived from the gateway URL.
|
|
// Format: "hs-<hostname>" with dots and special chars normalised to dashes.
|
|
// Falls back to "harness-sync-gateway" when the URL is empty or unparseable.
|
|
//
|
|
// https://llmgw.h.raczylo.com/v1 → hs-llmgw-h-raczylo-com
|
|
func GatewayProviderKey(gatewayURL string) string {
|
|
if gatewayURL == "" {
|
|
return fallbackGatewayProviderID
|
|
}
|
|
u, err := url.Parse(gatewayURL)
|
|
if err != nil || u.Hostname() == "" {
|
|
return fallbackGatewayProviderID
|
|
}
|
|
host := u.Hostname()
|
|
// Replace dots, underscores, and colons (port separators) with dashes.
|
|
r := strings.NewReplacer(".", "-", "_", "-", ":", "-")
|
|
return "hs-" + r.Replace(host)
|
|
}
|
|
|
|
// crushModel builds a 10-field model entry required by the crush schema.
|
|
func crushModel(m canonical.Model) map[string]any {
|
|
return map[string]any{
|
|
"id": m.ID,
|
|
"name": m.ID,
|
|
"cost_per_1m_in": 0,
|
|
"cost_per_1m_out": 0,
|
|
"cost_per_1m_in_cached": 0,
|
|
"cost_per_1m_out_cached": 0,
|
|
"context_window": m.ContextLimit(),
|
|
"default_max_tokens": m.OutputLimit(),
|
|
"can_reason": false,
|
|
"supports_attachments": true,
|
|
}
|
|
}
|
|
|
|
// ProvidersAsCrushMap returns a map[providerID]providerObject for the crush
|
|
// harness. Each provider has type, base_url, api_key, and models[].
|
|
// models[] entries carry all 10 required crush schema fields.
|
|
func ProvidersAsCrushMap(p *canonical.Profile) map[string]any {
|
|
out := map[string]any{}
|
|
if p.Gateway.URL == "" {
|
|
return out
|
|
}
|
|
models := make([]map[string]any, 0)
|
|
for _, m := range p.Models {
|
|
models = append(models, crushModel(m))
|
|
}
|
|
if len(models) == 0 && p.Gateway.DefaultModel != "" {
|
|
models = append(models, crushModel(canonical.Model{ID: p.Gateway.DefaultModel}))
|
|
}
|
|
out[GatewayProviderKey(p.Gateway.URL)] = map[string]any{
|
|
"type": "openai-compat",
|
|
"base_url": p.Gateway.URL,
|
|
"api_key": p.Gateway.Token,
|
|
"models": models,
|
|
}
|
|
return out
|
|
}
|
|
|
|
// CrushRoleModels returns the top-level models map for crush with large, small,
|
|
// and title roles all pointing at the gateway provider + default model.
|
|
func CrushRoleModels(p *canonical.Profile) map[string]any {
|
|
if p.Gateway.DefaultModel == "" {
|
|
return map[string]any{}
|
|
}
|
|
sel := map[string]any{
|
|
"model": p.Gateway.DefaultModel,
|
|
"provider": GatewayProviderKey(p.Gateway.URL),
|
|
}
|
|
return map[string]any{
|
|
"large": sel,
|
|
"small": sel,
|
|
"title": sel,
|
|
}
|
|
}
|
|
|
|
// ProvidersAsMap returns a name-keyed map of provider objects for harnesses
|
|
// like opencode/kilo that use `provider` (singular, map-shaped). Includes
|
|
// top-level "name" and "models" Record so the harness UI can enumerate models.
|
|
func ProvidersAsMap(p *canonical.Profile) map[string]any {
|
|
out := map[string]any{}
|
|
if p.Gateway.URL != "" {
|
|
models := map[string]any{}
|
|
for _, m := range p.Models {
|
|
displayName := m.ID
|
|
if m.Alias != "" {
|
|
displayName = m.Alias
|
|
}
|
|
models[m.ID] = map[string]any{
|
|
"name": displayName,
|
|
"limit": map[string]any{"context": m.ContextLimit(), "output": m.OutputLimit()},
|
|
}
|
|
}
|
|
entry := map[string]any{
|
|
"name": "harness-sync gateway",
|
|
"npm": "@ai-sdk/openai-compatible",
|
|
"options": map[string]any{
|
|
"baseURL": p.Gateway.URL,
|
|
"apiKey": p.Gateway.Token,
|
|
},
|
|
}
|
|
if len(models) > 0 {
|
|
entry["models"] = models
|
|
}
|
|
out[GatewayProviderKey(p.Gateway.URL)] = entry
|
|
}
|
|
for _, up := range p.Upstreams {
|
|
entry := map[string]any{
|
|
"npm": "@ai-sdk/openai-compatible",
|
|
}
|
|
opts := map[string]any{}
|
|
if up.BaseURL != "" {
|
|
opts["baseURL"] = up.BaseURL
|
|
}
|
|
if up.APIKey != "" {
|
|
opts["apiKey"] = up.APIKey
|
|
}
|
|
if len(opts) > 0 {
|
|
entry["options"] = opts
|
|
}
|
|
out[up.Name] = entry
|
|
}
|
|
return out
|
|
}
|
|
|
|
// KiloModelString returns the "providerID/modelID" string used by kilo/opencode.
|
|
func KiloModelString(p *canonical.Profile) string {
|
|
if p.Gateway.DefaultModel == "" {
|
|
return ""
|
|
}
|
|
return GatewayProviderKey(p.Gateway.URL) + "/" + p.Gateway.DefaultModel
|
|
}
|
|
|
|
// ProvidersAsCagentMap returns the providers block for cagent:
|
|
// map[providerID]{base_url, token_key, provider}.
|
|
func ProvidersAsCagentMap(p *canonical.Profile) map[string]any {
|
|
out := map[string]any{}
|
|
if p.Gateway.URL == "" {
|
|
return out
|
|
}
|
|
out[GatewayProviderKey(p.Gateway.URL)] = map[string]any{
|
|
"base_url": p.Gateway.URL,
|
|
"api_key": p.Gateway.Token,
|
|
"provider": "openai",
|
|
}
|
|
return out
|
|
}
|
|
|
|
// GooseCustomProviderFile returns the JSON body and provider ID for the goose
|
|
// custom_providers/<id>.json file. Returns nil body when Gateway.URL is empty.
|
|
func GooseCustomProviderFile(p *canonical.Profile) ([]byte, string) {
|
|
if p.Gateway.URL == "" {
|
|
return nil, ""
|
|
}
|
|
providerID := GatewayProviderKey(p.Gateway.URL)
|
|
providerName := "custom_" + providerID
|
|
|
|
models := make([]map[string]any, 0)
|
|
for _, m := range p.Models {
|
|
models = append(models, map[string]any{
|
|
"name": m.ID,
|
|
"context_limit": m.ContextLimit(),
|
|
})
|
|
}
|
|
if len(models) == 0 && p.Gateway.DefaultModel != "" {
|
|
// strip optional "provider/" prefix from DefaultModel
|
|
modelID := p.Gateway.DefaultModel
|
|
if idx := strings.IndexByte(modelID, '/'); idx >= 0 {
|
|
modelID = modelID[idx+1:]
|
|
}
|
|
models = append(models, map[string]any{
|
|
"name": modelID,
|
|
"context_limit": canonical.DefaultContextLimit,
|
|
})
|
|
}
|
|
|
|
// display_name: hostname of the gateway URL, or just providerID.
|
|
displayName := providerID
|
|
if idx := strings.Index(p.Gateway.URL, "://"); idx >= 0 {
|
|
rest := p.Gateway.URL[idx+3:]
|
|
if slash := strings.IndexByte(rest, '/'); slash >= 0 {
|
|
rest = rest[:slash]
|
|
}
|
|
if rest != "" {
|
|
displayName = rest
|
|
}
|
|
}
|
|
|
|
// Append only /chat/completions — the profile URL already contains /v1.
|
|
chatURL := strings.TrimRight(p.Gateway.URL, "/") + "/chat/completions"
|
|
|
|
entry := map[string]any{
|
|
"name": providerName,
|
|
"engine": "openai",
|
|
"display_name": displayName,
|
|
"base_url": chatURL,
|
|
"models": models,
|
|
"api_key": p.Gateway.Token,
|
|
}
|
|
body, err := json.MarshalIndent(entry, "", " ")
|
|
if err != nil {
|
|
return nil, ""
|
|
}
|
|
return body, providerName
|
|
}
|
|
|
|
// BuildMCPMap returns canonical MCP servers as a name -> object map.
|
|
// Empty map (not nil) is returned when there are no servers so the caller
|
|
// can decide whether to include the key.
|
|
func BuildMCPMap(reg *canonical.MCPRegistry) map[string]any {
|
|
out := map[string]any{}
|
|
if reg == nil {
|
|
return out
|
|
}
|
|
for _, s := range reg.Servers {
|
|
e := map[string]any{}
|
|
if s.Command != "" {
|
|
e["command"] = s.Command
|
|
}
|
|
if len(s.Args) > 0 {
|
|
e["args"] = s.Args
|
|
}
|
|
if s.URL != "" {
|
|
e["url"] = s.URL
|
|
}
|
|
if s.Transport != "" {
|
|
e["transport"] = s.Transport
|
|
}
|
|
if len(s.Env) > 0 {
|
|
e["env"] = s.Env
|
|
}
|
|
out[s.Name] = e
|
|
}
|
|
return out
|
|
}
|