WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,316 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// modelCreateFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `model create`. The result is the created Model.
|
||||
var modelCreateFields = []string{
|
||||
"id", "name", "display_name", "type", "source",
|
||||
"description", "is_default", "parameters", "created_at",
|
||||
}
|
||||
|
||||
// createSourceValues is the restricted --source set for `model create`. The
|
||||
// server's CreateModel only special-cases "remote" (registered active, routed
|
||||
// to a provider API); every other source falls into the local Ollama download
|
||||
// path. So create offers exactly the two working modes — the provider identity
|
||||
// for a remote model goes in --provider, not --source. (`model list --source`
|
||||
// still accepts the broad modelSourceValues for filtering pre-existing records.)
|
||||
var createSourceValues = []string{string(sdk.ModelSourceLocal), string(sdk.ModelSourceRemote)}
|
||||
|
||||
// canonicalModelType maps the server's frontend term "chat" to the KnowledgeQA
|
||||
// enum — that is the server's own /models/providers vocabulary (see its
|
||||
// frontendToModelType map). The other frontend terms (embedding / rerank /
|
||||
// vllm / asr) already match the enum case-insensitively, so no alias is needed.
|
||||
// Returns the input unchanged when it isn't an alias.
|
||||
func canonicalModelType(t string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(t), "chat") {
|
||||
return string(sdk.ModelTypeKnowledgeQA)
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
type CreateOptions struct {
|
||||
Name string
|
||||
DisplayName string
|
||||
Description string
|
||||
Type string
|
||||
Source string
|
||||
Provider string
|
||||
BaseURL string
|
||||
APIKeyStdin bool
|
||||
Dimension int
|
||||
Default bool
|
||||
Params []string // repeatable key=value → top-level Parameters entries
|
||||
DryRun bool
|
||||
StdinReader io.Reader // overridden by tests
|
||||
}
|
||||
|
||||
// CreateService is the narrow SDK surface this command depends on.
|
||||
// ListModelProviders supplies the authoritative provider catalog used to
|
||||
// validate --provider and default --base-url for remote models.
|
||||
type CreateService interface {
|
||||
CreateModel(ctx context.Context, req *sdk.CreateModelRequest) (*sdk.Model, error)
|
||||
ListModelProviders(ctx context.Context, modelType string) ([]sdk.ModelProvider, error)
|
||||
}
|
||||
|
||||
// frontendModelType maps the create enum to the server's /models/providers
|
||||
// "model_type" query vocabulary (KnowledgeQA→chat, VLLM→vllm; others lowercase).
|
||||
func frontendModelType(t string) string {
|
||||
switch t {
|
||||
case string(sdk.ModelTypeKnowledgeQA):
|
||||
return "chat"
|
||||
case string(sdk.ModelTypeVLLM):
|
||||
return "vllm"
|
||||
default:
|
||||
return strings.ToLower(t)
|
||||
}
|
||||
}
|
||||
|
||||
// NewCmdCreate builds `weknora model create <name>`.
|
||||
func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &CreateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "create <name>",
|
||||
Short: "Register a model (embedding / rerank / chat / VLLM / ASR)",
|
||||
Long: `Register a model on the server so it can back a knowledge base's embedding /
|
||||
summary config (see 'weknora kb config set') or an agent (--model).
|
||||
|
||||
<name> is the model name as the provider knows it (e.g. "nomic-embed-text",
|
||||
"gpt-4o", "qwen2"). --type and --source are required.
|
||||
|
||||
Two modes:
|
||||
|
||||
Local (Ollama): --source local
|
||||
The server pulls <name> from Ollama (async download).
|
||||
--base-url points at the Ollama endpoint when not default.
|
||||
|
||||
Remote (API): --source remote --provider <name> [--api-key-stdin] [--base-url <url>]
|
||||
Registered active and routed to the provider's API.
|
||||
--provider is required and is validated against the server's
|
||||
live provider catalog (weknora api /api/v1/models/providers);
|
||||
--base-url defaults to that provider's URL for the type when
|
||||
omitted.
|
||||
|
||||
--type accepts the server's term "chat" for KnowledgeQA (embedding/rerank/vllm/
|
||||
asr match the enum directly). Embedding models take --dimension. Pipe the
|
||||
provider key via --api-key-stdin so it never lands in argv/history. Anything
|
||||
else goes through repeatable --param key=value.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Name = args[0]
|
||||
// Validate + normalize enums (case-insensitive) before the dry-run
|
||||
// gate so --dry-run rejects identically to the live path. Mirrors
|
||||
// `model list`, which accepts the same flags case-insensitively and
|
||||
// fails an unknown value as input.invalid_argument (exit 5).
|
||||
canonType, err := cmdutil.ValidateEnum("type", canonicalModelType(opts.Type), modelTypeValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Type = canonType
|
||||
canonSource, err := cmdutil.ValidateEnum("source", opts.Source, createSourceValues)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Source = canonSource
|
||||
// Mode-specific guardrails: a remote model needs a provider to route
|
||||
// its API calls; a local (Ollama) model has no provider concept.
|
||||
if opts.Source == "remote" && strings.TrimSpace(opts.Provider) == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputMissingFlag,
|
||||
"--source remote requires --provider (e.g. openai, aliyun, deepseek)").
|
||||
WithHint("for a local Ollama model use --source local (no --provider)")
|
||||
}
|
||||
if opts.Source == "local" && strings.TrimSpace(opts.Provider) != "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"--provider applies to --source remote; a local model is pulled from Ollama by name").
|
||||
WithHint("drop --provider, or switch to --source remote")
|
||||
}
|
||||
params, err := parseParams(opts.Params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "model.create",
|
||||
// Never echo the API key into the plan (it is read from stdin
|
||||
// precisely so it never lands in argv / history / dry-run output).
|
||||
Args: map[string]any{"name": opts.Name, "type": opts.Type, "source": opts.Source, "provider": opts.Provider},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
if opts.StdinReader == nil {
|
||||
opts.StdinReader = iostreams.IO.In
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runCreate(c.Context(), opts, fopts, cli, params)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Type, "type", "", "Model type: "+strings.Join(modelTypeValues, " | ")+" (required; \"chat\" is accepted for KnowledgeQA)")
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Where the model runs: "+strings.Join(createSourceValues, " | ")+" (required; local=Ollama, remote=provider API)")
|
||||
cmd.Flags().StringVar(&opts.Provider, "provider", "", "Remote provider id, required+validated with --source remote (see `weknora api /api/v1/models/providers`)")
|
||||
cmd.Flags().StringVar(&opts.DisplayName, "display-name", "", "Human-friendly name (optional)")
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", "Description (optional)")
|
||||
cmd.Flags().StringVar(&opts.BaseURL, "base-url", "", "Model API base URL (e.g. http://localhost:11434 for Ollama)")
|
||||
cmd.Flags().BoolVar(&opts.APIKeyStdin, "api-key-stdin", false, "Read the provider API key from stdin (kept out of argv / history)")
|
||||
cmd.Flags().IntVar(&opts.Dimension, "dimension", 0, "Embedding dimension (Embedding models only)")
|
||||
cmd.Flags().BoolVar(&opts.Default, "default", false, "Mark this the default model for its type")
|
||||
cmd.Flags().StringArrayVar(&opts.Params, "param", nil, "Extra provider parameter as key=value, repeatable (value parsed as JSON: true/42/text)")
|
||||
_ = cmd.MarkFlagRequired("type")
|
||||
_ = cmd.MarkFlagRequired("source")
|
||||
cmdutil.AddFormatFlag(cmd, modelCreateFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "register a model (embedding/rerank/chat/VLLM/ASR) so a KB or agent can use it; capture .data.id to pass to `weknora kb config set` / `agent create --model`.",
|
||||
RequiredFlags: []string{"<name> (positional)", "--type", "--source (local|remote)", "--provider (when --source remote)"},
|
||||
Examples: []string{
|
||||
`weknora model create nomic-embed-text --type Embedding --source local --dimension 768 # Ollama (server pulls it)`,
|
||||
`printf '%s' "$OPENAI_KEY" | weknora model create text-embedding-3-small --type Embedding --source remote --provider openai --dimension 1536 --api-key-stdin`,
|
||||
},
|
||||
Output: "envelope.data is the created Model object with id, name, type, source, parameters",
|
||||
Warnings: []string{
|
||||
"Two modes: --source local (Ollama pulls <name>, async) vs --source remote --provider <id> (provider API). A provider name is NOT a --source value.",
|
||||
"Pass the API key via --api-key-stdin (piped), never as a flag — flag values leak into ps/history.",
|
||||
"A local model starts in a 'downloading' state and is unusable until the pull finishes; the embedding/chat call fails until then.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// parseParams turns repeated key=value flags into a map. Each value is parsed
|
||||
// as JSON so true/false, numbers, and objects keep their type (the server's
|
||||
// ModelParameters has typed fields like supports_vision bool); a value that
|
||||
// isn't valid JSON is kept as a plain string. Returns a typed flag error on a
|
||||
// malformed entry so the failure is exit 2, not a server 400.
|
||||
func parseParams(kvs []string) (map[string]any, error) {
|
||||
if len(kvs) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
out := make(map[string]any, len(kvs))
|
||||
for _, kv := range kvs {
|
||||
k, v, ok := strings.Cut(kv, "=")
|
||||
if !ok || strings.TrimSpace(k) == "" {
|
||||
return nil, cmdutil.NewFlagError(fmt.Errorf("invalid --param %q: expected key=value", kv))
|
||||
}
|
||||
var parsed any
|
||||
if json.Unmarshal([]byte(v), &parsed) == nil {
|
||||
out[k] = parsed // true/false, numbers, JSON objects/arrays
|
||||
} else {
|
||||
out[k] = v // plain string (the common case)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOptions, svc CreateService, params map[string]any) error {
|
||||
// Remote models: validate --provider against the server's live provider
|
||||
// catalog for this model type, and default --base-url from it when omitted.
|
||||
// Uses the authoritative /models/providers data (via the SDK) instead of a
|
||||
// hardcoded list, so the CLI never drifts from the server.
|
||||
if opts.Source == string(sdk.ModelSourceRemote) {
|
||||
if err := resolveRemoteProvider(ctx, svc, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
parameters := sdk.ModelParameters{}
|
||||
for k, v := range params {
|
||||
parameters[k] = v
|
||||
}
|
||||
if opts.Provider != "" {
|
||||
parameters["provider"] = opts.Provider
|
||||
}
|
||||
if opts.BaseURL != "" {
|
||||
parameters["base_url"] = opts.BaseURL
|
||||
}
|
||||
if opts.Dimension > 0 {
|
||||
parameters["embedding_parameters"] = map[string]any{"dimension": opts.Dimension}
|
||||
}
|
||||
if opts.APIKeyStdin {
|
||||
key, err := readStdinTrimmed(opts.StdinReader)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read API key from stdin")
|
||||
}
|
||||
if key == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--api-key-stdin requires the key piped to stdin")
|
||||
}
|
||||
parameters["api_key"] = key
|
||||
}
|
||||
|
||||
req := &sdk.CreateModelRequest{
|
||||
Name: opts.Name,
|
||||
DisplayName: opts.DisplayName,
|
||||
Type: sdk.ModelType(opts.Type),
|
||||
Source: sdk.ModelSource(opts.Source),
|
||||
Description: opts.Description,
|
||||
Parameters: parameters,
|
||||
IsDefault: opts.Default,
|
||||
}
|
||||
created, err := svc.CreateModel(ctx, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "create model")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, created, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Created model %q (id: %s, type: %s)\n", created.Name, created.ID, created.Type)
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveRemoteProvider validates opts.Provider against the server's provider
|
||||
// catalog for the model's type and, when --base-url was omitted, defaults it
|
||||
// from the provider's catalog entry. Canonicalizes the provider's casing.
|
||||
func resolveRemoteProvider(ctx context.Context, svc CreateService, opts *CreateOptions) error {
|
||||
ft := frontendModelType(opts.Type)
|
||||
providers, err := svc.ListModelProviders(ctx, ft)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list model providers")
|
||||
}
|
||||
for i := range providers {
|
||||
if strings.EqualFold(providers[i].Value, opts.Provider) {
|
||||
opts.Provider = providers[i].Value // canonicalize casing
|
||||
if opts.BaseURL == "" {
|
||||
opts.BaseURL = providers[i].DefaultURLs[ft]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
vals := make([]string, len(providers))
|
||||
for i, p := range providers {
|
||||
vals[i] = p.Value
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
fmt.Sprintf("unknown --provider %q for %s models", opts.Provider, opts.Type)).
|
||||
WithHint("supported providers: " + strings.Join(vals, ", "))
|
||||
}
|
||||
|
||||
// readStdinTrimmed reads all of r and returns it whitespace-trimmed.
|
||||
func readStdinTrimmed(r io.Reader) (string, error) {
|
||||
if r == nil {
|
||||
return "", nil
|
||||
}
|
||||
b, err := io.ReadAll(r)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return strings.TrimSpace(string(b)), nil
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements CreateService.
|
||||
var _ CreateService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,237 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeCreateSvc struct {
|
||||
got *sdk.CreateModelRequest
|
||||
resp *sdk.Model
|
||||
err error
|
||||
providers []sdk.ModelProvider // returned by ListModelProviders; nil → default catalog
|
||||
}
|
||||
|
||||
func (f *fakeCreateSvc) CreateModel(_ context.Context, req *sdk.CreateModelRequest) (*sdk.Model, error) {
|
||||
f.got = req
|
||||
if f.resp == nil {
|
||||
f.resp = &sdk.Model{ID: "model_new", Name: req.Name, Type: req.Type, Source: req.Source}
|
||||
}
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
func (f *fakeCreateSvc) ListModelProviders(_ context.Context, _ string) ([]sdk.ModelProvider, error) {
|
||||
if f.providers != nil {
|
||||
return f.providers, nil
|
||||
}
|
||||
return []sdk.ModelProvider{
|
||||
{Value: "openai", ModelTypes: []string{"chat", "embedding"},
|
||||
DefaultURLs: map[string]string{"chat": "https://api.openai.com/v1", "embedding": "https://api.openai.com/v1"}},
|
||||
{Value: "aliyun", ModelTypes: []string{"chat", "embedding", "rerank"},
|
||||
DefaultURLs: map[string]string{"embedding": "https://dashscope.example/v1"}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestModelCreate_BuildsRequest(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
opts := &CreateOptions{
|
||||
Name: "nomic-embed-text", Type: "Embedding", Source: "local",
|
||||
BaseURL: "http://localhost:11434", Dimension: 768, Default: true,
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts,
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, map[string]any{"provider": "ollama"}))
|
||||
|
||||
require.NotNil(t, svc.got)
|
||||
assert.Equal(t, "nomic-embed-text", svc.got.Name)
|
||||
assert.Equal(t, sdk.ModelTypeEmbedding, svc.got.Type)
|
||||
assert.Equal(t, sdk.ModelSource("local"), svc.got.Source)
|
||||
assert.True(t, svc.got.IsDefault)
|
||||
assert.Equal(t, "http://localhost:11434", svc.got.Parameters["base_url"])
|
||||
assert.Equal(t, "ollama", svc.got.Parameters["provider"])
|
||||
emb, ok := svc.got.Parameters["embedding_parameters"].(map[string]any)
|
||||
require.True(t, ok, "embedding_parameters should be a nested map")
|
||||
assert.Equal(t, 768, emb["dimension"])
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Model `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "model_new", env.Data.ID)
|
||||
}
|
||||
|
||||
func TestModelCreate_APIKeyFromStdin(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
opts := &CreateOptions{
|
||||
Name: "text-embedding-3-small", Type: "Embedding", Source: "remote", Provider: "openai",
|
||||
APIKeyStdin: true, StdinReader: strings.NewReader(" sk-secret\n"),
|
||||
}
|
||||
require.NoError(t, runCreate(context.Background(), opts,
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, nil))
|
||||
assert.Equal(t, "sk-secret", svc.got.Parameters["api_key"], "key must be trimmed and set from stdin")
|
||||
assert.Equal(t, "openai", svc.got.Parameters["provider"], "--provider maps into parameters.provider")
|
||||
}
|
||||
|
||||
// TestModelCreate_RemoteProviderValidatedAndBaseURLDefaulted: for a remote
|
||||
// model the provider is validated against the server's catalog and --base-url
|
||||
// is defaulted from it (not hardcoded in the CLI).
|
||||
func TestModelCreate_RemoteProviderValidatedAndBaseURLDefaulted(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
// Known provider, no --base-url → defaulted from the catalog entry.
|
||||
svc := &fakeCreateSvc{}
|
||||
require.NoError(t, runCreate(context.Background(),
|
||||
&CreateOptions{Name: "text-embedding-3-small", Type: "Embedding", Source: "remote", Provider: "openai"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, nil))
|
||||
assert.Equal(t, "openai", svc.got.Parameters["provider"])
|
||||
assert.Equal(t, "https://api.openai.com/v1", svc.got.Parameters["base_url"], "base_url defaulted from provider catalog")
|
||||
|
||||
// Explicit --base-url is respected (not overridden by the catalog default).
|
||||
svc2 := &fakeCreateSvc{}
|
||||
require.NoError(t, runCreate(context.Background(),
|
||||
&CreateOptions{Name: "m", Type: "Embedding", Source: "remote", Provider: "openai", BaseURL: "https://proxy.local/v1"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc2, nil))
|
||||
assert.Equal(t, "https://proxy.local/v1", svc2.got.Parameters["base_url"])
|
||||
|
||||
// Unknown provider → input.invalid_argument, no model created.
|
||||
svc3 := &fakeCreateSvc{}
|
||||
err := runCreate(context.Background(),
|
||||
&CreateOptions{Name: "x", Type: "Embedding", Source: "remote", Provider: "bogus-provider"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc3, nil)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
|
||||
assert.Nil(t, svc3.got, "must not create a model when --provider is unknown")
|
||||
}
|
||||
|
||||
func TestModelCreate_APIKeyStdinEmpty(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
opts := &CreateOptions{Name: "m", Type: "Embedding", Source: "openai", APIKeyStdin: true, StdinReader: strings.NewReader("")}
|
||||
err := runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, nil)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, ce.Code)
|
||||
assert.Nil(t, svc.got, "must not call CreateModel when stdin key is empty")
|
||||
}
|
||||
|
||||
func TestParseParams(t *testing.T) {
|
||||
// Bare words aren't valid JSON → kept as strings (the common case).
|
||||
m, err := parseParams([]string{"provider=openai", "interface_type=chat"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "openai", m["provider"])
|
||||
assert.Equal(t, "chat", m["interface_type"])
|
||||
|
||||
// JSON-typed values keep their type so the server's typed ModelParameters
|
||||
// fields (e.g. supports_vision bool) bind correctly.
|
||||
typed, err := parseParams([]string{"supports_vision=true", "n=42", "name=plain"})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, true, typed["supports_vision"])
|
||||
assert.Equal(t, float64(42), typed["n"])
|
||||
assert.Equal(t, "plain", typed["name"])
|
||||
|
||||
_, err = parseParams([]string{"noequals"})
|
||||
require.Error(t, err)
|
||||
var fe *cmdutil.FlagError
|
||||
assert.ErrorAs(t, err, &fe)
|
||||
}
|
||||
|
||||
// withRootHarnessModel wraps a model subcommand under a synthetic root with the
|
||||
// global persistent flags so flag/enum validation and gating route correctly.
|
||||
func withRootHarnessModel(sub *cobra.Command, args ...string) *cobra.Command {
|
||||
root := &cobra.Command{Use: "weknora"}
|
||||
pf := root.PersistentFlags()
|
||||
pf.BoolP("yes", "y", false, "")
|
||||
pf.String("format", "", "")
|
||||
pf.StringP("jq", "q", "", "")
|
||||
model := &cobra.Command{Use: "model"}
|
||||
model.AddCommand(sub)
|
||||
root.AddCommand(model)
|
||||
root.SetArgs(append([]string{"model", sub.Name()}, args...))
|
||||
root.SetContext(context.Background())
|
||||
root.SilenceErrors = true
|
||||
root.SilenceUsage = true
|
||||
return root
|
||||
}
|
||||
|
||||
func TestModelCreate_InvalidTypeRejected(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)),
|
||||
"my-model", "--type", "Bogus", "--source", "local", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
// An unknown enum VALUE is input.invalid_argument (exit 5) — same as
|
||||
// `model list`, not a cobra parse error (exit 2).
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
|
||||
assert.Equal(t, 5, cmdutil.ExitCode(err))
|
||||
}
|
||||
|
||||
// TestModelCreate_NormalizesTypeAliasAndSourceCase: --type accepts friendly
|
||||
// aliases (chat → KnowledgeQA) and --source is case-insensitive, both
|
||||
// normalized to canonical form in the plan.
|
||||
func TestModelCreate_NormalizesTypeAliasAndSourceCase(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)),
|
||||
"m", "--type", "chat", "--source", "REMOTE", "--provider", "openai", "--dry-run", "--format", "json")
|
||||
require.NoError(t, root.Execute())
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
args, ok := env.Meta.Plan["args"].(map[string]any)
|
||||
require.True(t, ok, "plan.args should be present; got %v", env.Meta.Plan)
|
||||
assert.Equal(t, "KnowledgeQA", args["type"], "chat alias normalized to KnowledgeQA")
|
||||
assert.Equal(t, "remote", args["source"], "source normalized to lowercase canonical")
|
||||
}
|
||||
|
||||
// TestModelCreate_RemoteRequiresProvider: --source remote without --provider is
|
||||
// rejected (the server can't route a remote model without a provider).
|
||||
func TestModelCreate_RemoteRequiresProvider(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)),
|
||||
"gpt-4o", "--type", "KnowledgeQA", "--source", "remote", "--format", "json")
|
||||
err := root.Execute()
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, ce.Code)
|
||||
}
|
||||
|
||||
// TestModelCreate_LocalRejectsProvider: --provider with --source local is a
|
||||
// contradiction (a local Ollama model has no provider).
|
||||
func TestModelCreate_LocalRejectsProvider(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)),
|
||||
"qwen2", "--type", "KnowledgeQA", "--source", "local", "--provider", "openai", "--format", "json")
|
||||
err := root.Execute()
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
|
||||
}
|
||||
|
||||
// TestModelCreate_RejectsProviderNameAsSource: a provider name is not a valid
|
||||
// --source (only local/remote) — guards the misleading-source regression.
|
||||
func TestModelCreate_RejectsProviderNameAsSource(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
root := withRootHarnessModel(NewCmdCreate(modelDryRunFactory(t)),
|
||||
"x", "--type", "Embedding", "--source", "openai", "--format", "json")
|
||||
err := root.Execute()
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, ce.Code)
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
type DeleteService interface {
|
||||
DeleteModel(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
type deleteOptions struct {
|
||||
Yes bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// NewCmdDelete builds `weknora model delete <model-id>`.
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &deleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <model-id>",
|
||||
Short: "Delete a model",
|
||||
Long: `Delete a model by id. High-risk write: without -y/--yes in a non-TTY / JSON
|
||||
context it exits 10 (input.confirmation_required) without deleting. A KB or
|
||||
agent still referencing the model will fail until repointed.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
id := args[0]
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "model.delete",
|
||||
Args: map[string]any{"model": id},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(),
|
||||
"delete", "model", id, "model.delete", []string{"weknora", "model", "delete", id, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
return runDelete(c.Context(), fopts, cli, id)
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, "id", "deleted")
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "model.delete")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "delete a model by id",
|
||||
RequiredFlags: []string{"<model-id> (positional)"},
|
||||
Examples: []string{"weknora model delete model_abc -y"},
|
||||
Output: "envelope.data is {id, deleted:true}",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"A knowledge base or agent still referencing this model will fail until repointed.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, fopts *cmdutil.FormatOptions, svc DeleteService, id string) error {
|
||||
if err := svc.DeleteModel(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete model %q", id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, map[string]any{"id": id, "deleted": true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted model %s\n", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements DeleteService.
|
||||
var _ DeleteService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,103 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/prompt"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// modelDryRunFactory builds a Factory whose Client / Prompter closures fail the
|
||||
// test if invoked — used by paths (flag validation, dry-run) that must not
|
||||
// reach the SDK. For the confirmation test, a permissive factory is built inline.
|
||||
func modelDryRunFactory(t *testing.T) *cmdutil.Factory {
|
||||
t.Helper()
|
||||
return &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) {
|
||||
t.Fatal("path must not call Factory.Client()")
|
||||
return nil, nil
|
||||
},
|
||||
Prompter: func() prompt.Prompter {
|
||||
t.Fatal("path must not call Factory.Prompter()")
|
||||
return nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type fakeDeleteSvc struct {
|
||||
gotID string
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeDeleteSvc) DeleteModel(_ context.Context, id string) error {
|
||||
f.gotID = id
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestModelDelete_CallsSDKAndEmits(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
require.NoError(t, runDelete(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "model_abc"))
|
||||
assert.Equal(t, "model_abc", svc.gotID)
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "model_abc", env.Data.ID)
|
||||
assert.True(t, env.Data.Deleted)
|
||||
}
|
||||
|
||||
func TestModelDelete_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runDelete(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
|
||||
require.Error(t, err)
|
||||
assert.True(t, cmdutil.IsNotFound(err))
|
||||
}
|
||||
|
||||
// TestModelDelete_RequiresConfirmation: without -y (non-TTY/JSON), delete exits
|
||||
// 10 (input.confirmation_required) and never reaches the SDK.
|
||||
func TestModelDelete_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarnessModel(NewCmdDelete(f), "model_abc", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, ce.Code)
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
assert.Contains(t, ce.RetryArgv, "-y")
|
||||
}
|
||||
|
||||
// TestModelDelete_DryRun: --dry-run previews without reaching the SDK.
|
||||
func TestModelDelete_DryRun(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
root := withRootHarnessModel(NewCmdDelete(modelDryRunFactory(t)), "model_abc", "--dry-run", "--format", "json")
|
||||
require.NoError(t, root.Execute())
|
||||
var env struct {
|
||||
Meta struct {
|
||||
DryRun bool `json:"dry_run"`
|
||||
Plan map[string]any `json:"plan"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.Meta.DryRun)
|
||||
assert.Equal(t, "model.delete", env.Meta.Plan["action"])
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// modelListFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `model list`. The nested `parameters` object is omitted — use `--jq` for
|
||||
// it (and `model view` renders the full record).
|
||||
var modelListFields = []string{
|
||||
"id", "name", "display_name", "type", "source",
|
||||
"description", "is_default", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// modelTypeValues / modelSourceValues are the closed server enums the
|
||||
// corresponding filter flags accept, sourced from the SDK's enumerators so the
|
||||
// CLI can't drift from the SDK/server. A typo is rejected up front rather than
|
||||
// silently returning an empty set (which an agent cannot distinguish from a
|
||||
// genuine no-match).
|
||||
var modelTypeValues = cmdutil.EnumStrings(sdk.AllModelTypes())
|
||||
|
||||
var modelSourceValues = cmdutil.EnumStrings(sdk.AllModelSources())
|
||||
|
||||
// ListOptions captures `model list` filter flag state.
|
||||
type ListOptions struct {
|
||||
// Type / Source, when set, restrict output to models of that type
|
||||
// (Embedding, Rerank, KnowledgeQA, VLLM, ASR) or provider (local, openai,
|
||||
// …), matched case-insensitively. Empty shows everything.
|
||||
Type string
|
||||
Source string
|
||||
// Limit caps the returned slice client-side (applied after --type/--source
|
||||
// filtering and sort). The ListModels SDK is unpaginated, so the CLI holds
|
||||
// the true total and reports meta.total_count/has_more when --limit drops any.
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
type ListService interface {
|
||||
ListModels(ctx context.Context) ([]sdk.Model, error)
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora model list`.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List models configured on the server",
|
||||
Long: `List the models configured on the server, sorted by type then name. Pass
|
||||
--type to restrict to one model type (Embedding, Rerank, KnowledgeQA, VLLM, ASR).`,
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(c *cobra.Command, _ []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Validate static input before building the client so a bad --limit
|
||||
// returns input.invalid_argument (exit 5), not an auth error (exit 3).
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runList(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Type, "type", "", "Only show models of this type (Embedding, Rerank, KnowledgeQA, VLLM, ASR)")
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Only show models from this provider (local, remote, openai, aliyun, …)")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
|
||||
cmdutil.AddFormatFlag(cmd, modelListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "discover model ids for `agent create --model` and a KB's embedding/summary model",
|
||||
Examples: []string{
|
||||
"weknora model list",
|
||||
"weknora model list --type KnowledgeQA --format json",
|
||||
"weknora model list --source local",
|
||||
},
|
||||
Output: "envelope.data is an array of Model objects (id, name, display_name, type, source, is_default); narrow it with --type / --source; meta.count is the returned count, meta.total_count is the full set and meta.has_more=true means --limit truncated it",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --limit. Called from RunE before the client is built
|
||||
// (so a bad value surfaces as exit 5, not an auth error) and at runList's top
|
||||
// for direct callers; idempotent.
|
||||
func validateListOpts(opts *ListOptions) error {
|
||||
if opts.Limit < 1 || opts.Limit > 10000 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cmdutil.ValidateEnum("type", opts.Type, modelTypeValues); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cmdutil.ValidateEnum("source", opts.Source, modelSourceValues); err != nil {
|
||||
return err
|
||||
}
|
||||
items, err := svc.ListModels(ctx)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list models")
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Model{} // ensure JSON [] not null
|
||||
}
|
||||
hasFilter := opts.Type != "" || opts.Source != ""
|
||||
if hasFilter {
|
||||
filtered := items[:0]
|
||||
for _, m := range items {
|
||||
if opts.Type != "" && !strings.EqualFold(string(m.Type), opts.Type) {
|
||||
continue
|
||||
}
|
||||
if opts.Source != "" && !strings.EqualFold(string(m.Source), opts.Source) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, m)
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
// Deterministic order: by type, then label. Server return order is not
|
||||
// guaranteed, so a client-side sort keeps output stable.
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Type != items[j].Type {
|
||||
return items[i].Type < items[j].Type
|
||||
}
|
||||
return modelLabel(items[i]) < modelLabel(items[j])
|
||||
})
|
||||
|
||||
// Client-side --limit cap. The ListModels SDK is unpaginated, so the CLI
|
||||
// holds the true total and can tell the caller whether --limit dropped any:
|
||||
// total_count is the full (post-filter) count, has_more flags truncation.
|
||||
total := len(items)
|
||||
truncated := false
|
||||
if opts.Limit > 0 && len(items) > opts.Limit {
|
||||
items = items[:opts.Limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
if hasFilter {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no models match the filter)")
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no models)")
|
||||
return nil
|
||||
}
|
||||
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tNAME\tTYPE\tSOURCE\tDEFAULT")
|
||||
for _, m := range items {
|
||||
def := ""
|
||||
if m.IsDefault {
|
||||
def = "default"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\t%s\n",
|
||||
m.ID, text.Truncate(40, modelLabel(m)), m.Type, m.Source, def)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeListSvc struct {
|
||||
models []sdk.Model
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeListSvc) ListModels(_ context.Context) ([]sdk.Model, error) {
|
||||
return f.models, f.err
|
||||
}
|
||||
|
||||
func TestModelList_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: []sdk.Model{
|
||||
{ID: "m1", DisplayName: "GPT-X", Type: sdk.ModelTypeKnowledgeQA, Source: sdk.ModelSourceOpenAI, IsDefault: true},
|
||||
{ID: "m2", Name: "bge", Type: sdk.ModelTypeEmbedding, Source: sdk.ModelSourceLocal},
|
||||
}}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"ID", "NAME", "TYPE", "SOURCE", "m1", "GPT-X", "KnowledgeQA", "m2", "bge", "Embedding", "default"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelList_TypeFilter: --type narrows the set case-insensitively, and the
|
||||
// JSON envelope's meta.count reflects the filtered total.
|
||||
func TestModelList_TypeFilter(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: []sdk.Model{
|
||||
{ID: "m1", Type: sdk.ModelTypeKnowledgeQA},
|
||||
{ID: "m2", Type: sdk.ModelTypeEmbedding},
|
||||
}}
|
||||
if err := runList(context.Background(), &ListOptions{Type: "embedding", Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data []sdk.Model `json:"data"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(env.Data) != 1 || env.Data[0].ID != "m2" {
|
||||
t.Errorf("expected only m2 (Embedding), got %+v", env.Data)
|
||||
}
|
||||
if env.Meta.Count != 1 {
|
||||
t.Errorf("meta.count = %d, want 1", env.Meta.Count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelList_Empty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: nil}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "(no models)") {
|
||||
t.Errorf("expected empty marker, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelList_EmptyAfterFilter(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: []sdk.Model{{ID: "m1", Type: sdk.ModelTypeEmbedding}}}
|
||||
if err := runList(context.Background(), &ListOptions{Type: "Rerank", Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "(no models match the filter)") {
|
||||
t.Errorf("expected filter empty marker, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelList_SourceFilter: --source narrows by provider, case-insensitively.
|
||||
func TestModelList_SourceFilter(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: []sdk.Model{
|
||||
{ID: "m1", Type: sdk.ModelTypeEmbedding, Source: sdk.ModelSourceLocal},
|
||||
{ID: "m2", Type: sdk.ModelTypeKnowledgeQA, Source: sdk.ModelSourceOpenAI},
|
||||
}}
|
||||
if err := runList(context.Background(), &ListOptions{Source: "OpenAI", Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data []sdk.Model `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(env.Data) != 1 || env.Data[0].ID != "m2" {
|
||||
t.Errorf("expected only m2 (openai), got %+v", env.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelList_InvalidEnum: a typo'd --type / --source is rejected up front
|
||||
// (input.invalid_argument) instead of silently returning an empty set.
|
||||
func TestModelList_InvalidEnum(t *testing.T) {
|
||||
for _, tc := range []struct{ name string; opts ListOptions }{
|
||||
{"type", ListOptions{Type: "bogus", Limit: 30}},
|
||||
{"source", ListOptions{Source: "bogus", Limit: 30}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: []sdk.Model{{ID: "m1", Type: sdk.ModelTypeEmbedding, Source: sdk.ModelSourceLocal}}}
|
||||
err := runList(context.Background(), &tc.opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected input.invalid_argument, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelList_Limit_TruncatesAndSignals verifies --limit caps model list
|
||||
// output and reports meta.total_count/has_more like every other list command.
|
||||
// Regression: model list had no --limit, so an agent could not cap output or
|
||||
// tell whether it saw the full set.
|
||||
func TestModelList_Limit_TruncatesAndSignals(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: []sdk.Model{
|
||||
{ID: "m1", Type: sdk.ModelTypeKnowledgeQA},
|
||||
{ID: "m2", Type: sdk.ModelTypeKnowledgeQA},
|
||||
{ID: "m3", Type: sdk.ModelTypeKnowledgeQA},
|
||||
}}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 2}, fopts, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if n := strings.Count(got, `"id":"m`); n != 2 {
|
||||
t.Errorf("--limit 2 should slice to 2 models, got %d in:\n%s", n, got)
|
||||
}
|
||||
if !strings.Contains(got, `"has_more":true`) {
|
||||
t.Errorf("truncated model list must set has_more:true; got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"total_count":3`) {
|
||||
t.Errorf("truncated model list must report total_count:3; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelList_BadLimit_Rejected verifies an out-of-range --limit is exit-5
|
||||
// typed validation, consistent with kb/session list.
|
||||
func TestModelList_BadLimit_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(), &ListOptions{Limit: 99999}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{})
|
||||
if err == nil {
|
||||
t.Fatal("--limit 99999 must be rejected")
|
||||
}
|
||||
var e *cmdutil.Error
|
||||
if !errors.As(err, &e) || e.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("want input.invalid_argument, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Package modelcmd holds the `weknora model` command tree: list / view.
|
||||
//
|
||||
// These are read-only discovery commands. The model id is a required input to
|
||||
// `weknora agent create --model <id>` and to a knowledge base's embedding /
|
||||
// summary model settings, so the CLI must offer a first-class way to find it
|
||||
// rather than forcing callers down to the raw `weknora api` escape hatch.
|
||||
//
|
||||
// The directory is named `model/` to match the cobra subcommand; the Go
|
||||
// package is `modelcmd` to avoid colliding with the SDK's Model type.
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora model` parent and registers leaves. Called from
|
||||
// cli/cmd/root.go.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "model",
|
||||
Short: "Manage models (list / view / create / update / delete)",
|
||||
Long: `List, inspect, register, and delete the models configured on the server. Use
|
||||
the model id to back a knowledge base's embedding / summary config ('weknora kb
|
||||
init') or an agent ('weknora agent create --model <id>').`,
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdCreate(f))
|
||||
cmd.AddCommand(NewCmdUpdate(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
// modelLabel prefers display_name, falling back to the raw name.
|
||||
func modelLabel(m sdk.Model) string {
|
||||
if m.DisplayName != "" {
|
||||
return m.DisplayName
|
||||
}
|
||||
return m.Name
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// UpdateOptions captures the surgical flag state for `model update`. Per-flag
|
||||
// *Set bits distinguish "" (clear) from unset, matching agent/doc update.
|
||||
type UpdateOptions struct {
|
||||
DisplayName string
|
||||
Description string
|
||||
BaseURL string
|
||||
APIKeyStdin bool
|
||||
Params []string
|
||||
Default bool
|
||||
DryRun bool
|
||||
StdinReader io.Reader
|
||||
flags modelUpdateFlags
|
||||
}
|
||||
|
||||
type modelUpdateFlags struct{ displayName, description, baseURL, def bool }
|
||||
|
||||
// UpdateService is the narrow SDK surface. UpdateModel is a full PUT, so the
|
||||
// fetch (GetModel) is mandatory — without the baseline, any field not touched
|
||||
// by a flag would clobber to its zero value.
|
||||
type UpdateService interface {
|
||||
GetModel(ctx context.Context, id string) (*sdk.Model, error)
|
||||
UpdateModel(ctx context.Context, id string, req *sdk.UpdateModelRequest) (*sdk.Model, error)
|
||||
}
|
||||
|
||||
// NewCmdUpdate builds `weknora model update <model-id>` — update a registered
|
||||
// model in place (id preserved), so KBs / agents referencing it keep working.
|
||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &UpdateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <model-id>",
|
||||
Short: "Update a model in place (rotate key, base URL, display name, default)",
|
||||
Long: `Update a registered model WITHOUT changing its id, so KBs / agents that
|
||||
reference it keep working (unlike delete + re-create, which orphans references).
|
||||
Rotate the provider key with --api-key-stdin, or change --base-url,
|
||||
--display-name, --description, extra --param entries, or --default. A model's
|
||||
type and source are immutable — register a new model to change them.
|
||||
|
||||
Reversible write: without -y/--yes in a non-TTY / JSON context it exits 10
|
||||
(input.confirmation_required) without applying the change.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
id := args[0]
|
||||
opts.flags.displayName = c.Flags().Changed("display-name")
|
||||
opts.flags.description = c.Flags().Changed("description")
|
||||
opts.flags.baseURL = c.Flags().Changed("base-url")
|
||||
opts.flags.def = c.Flags().Changed("default")
|
||||
if !modelUpdateHasFlag(opts) {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "model update requires at least one flag",
|
||||
Hint: "pass e.g. --display-name, --base-url, --api-key-stdin, --param, or --default",
|
||||
}
|
||||
}
|
||||
params, err := parseParams(opts.Params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "model.update",
|
||||
Args: map[string]any{"model": id, "display_name": opts.DisplayName, "base_url": opts.BaseURL, "default": opts.Default, "rotate_api_key": opts.APIKeyStdin, "param_count": len(params)},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
yes, _ := c.Flags().GetBool("yes")
|
||||
// --api-key-stdin / --param excluded from retry_argv (stdin secret /
|
||||
// repeatable), matching agent update's multi-value exclusions.
|
||||
retry := cmdutil.BuildRetryArgv(c, []string{"weknora", "model", "update", id},
|
||||
"display-name", "description", "base-url", "default", "format")
|
||||
if err := cmdutil.ConfirmWrite(f.Prompter(), yes, fopts.WantsJSON(), "update", "model", id, "model.update", retry); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.StdinReader == nil {
|
||||
opts.StdinReader = iostreams.IO.In
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runUpdate(c.Context(), opts, fopts, cli, id, params)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.DisplayName, "display-name", "", "New human-friendly name")
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", "New description")
|
||||
cmd.Flags().StringVar(&opts.BaseURL, "base-url", "", "New model API base URL")
|
||||
cmd.Flags().BoolVar(&opts.APIKeyStdin, "api-key-stdin", false, "Rotate the provider API key, read from stdin (kept out of argv / history)")
|
||||
cmd.Flags().StringArrayVar(&opts.Params, "param", nil, "Set an extra provider parameter as key=value, repeatable (value parsed as JSON)")
|
||||
cmd.Flags().BoolVar(&opts.Default, "default", false, "Mark this the default model for its type (--default=false to unset)")
|
||||
cmdutil.AddFormatFlag(cmd, modelListFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetWriteRisk(cmd, "model.update")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "update a registered model IN PLACE (id preserved, so KB/agent references keep working): rotate --api-key-stdin, change --base-url / --display-name / --description / --param, or set --default. Type and source are immutable.",
|
||||
RequiredFlags: []string{"<model-id> (positional)", "at least one update flag"},
|
||||
Examples: []string{
|
||||
`printf '%s' "$NEW_KEY" | weknora model update mdl_abc --api-key-stdin -y`,
|
||||
`weknora model update mdl_abc --base-url https://api.example.com/v1 -y`,
|
||||
`weknora model update mdl_abc --default -y`,
|
||||
},
|
||||
Output: "envelope.data is the updated Model object (id preserved; provider api key never echoed)",
|
||||
Warnings: []string{
|
||||
"Reversible write: requires explicit approval (exit 10 / input.confirmation_required) unless -y; never auto-add -y.",
|
||||
"Server-side this is an admin operation; a non-admin credential gets auth.forbidden (exit 3).",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func modelUpdateHasFlag(o *UpdateOptions) bool {
|
||||
return o.flags.displayName || o.flags.description || o.flags.baseURL || o.flags.def ||
|
||||
o.APIKeyStdin || len(o.Params) > 0
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, opts *UpdateOptions, fopts *cmdutil.FormatOptions, svc UpdateService, id string, params map[string]any) error {
|
||||
// Fetch-then-update: UpdateModel is a full PUT, so start from the server's
|
||||
// current state and overlay only what the user changed.
|
||||
cur, err := svc.GetModel(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch model %s", id)
|
||||
}
|
||||
merged := sdk.ModelParameters{}
|
||||
for k, v := range cur.Parameters {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range params {
|
||||
merged[k] = v
|
||||
}
|
||||
if opts.flags.baseURL {
|
||||
merged["base_url"] = opts.BaseURL
|
||||
}
|
||||
if opts.APIKeyStdin {
|
||||
key, err := readStdinTrimmed(opts.StdinReader)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read API key from stdin")
|
||||
}
|
||||
if key == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--api-key-stdin requires the key piped to stdin")
|
||||
}
|
||||
merged["api_key"] = key
|
||||
}
|
||||
|
||||
req := &sdk.UpdateModelRequest{
|
||||
Name: cur.Name,
|
||||
DisplayName: cur.DisplayName,
|
||||
Description: cur.Description,
|
||||
Parameters: merged,
|
||||
IsDefault: cur.IsDefault,
|
||||
}
|
||||
if opts.flags.displayName {
|
||||
req.DisplayName = opts.DisplayName
|
||||
}
|
||||
if opts.flags.description {
|
||||
req.Description = opts.Description
|
||||
}
|
||||
if opts.flags.def {
|
||||
req.IsDefault = opts.Default
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateModel(ctx, id, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "update model %s", id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, updated, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Updated model %q (id: %s)\n", updated.Name, updated.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements UpdateService.
|
||||
var _ UpdateService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,81 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// fakeUpdateSvc scripts GetModel (fetch baseline) and captures the
|
||||
// UpdateModelRequest so tests can assert the surgical overlay.
|
||||
type fakeUpdateSvc struct {
|
||||
cur *sdk.Model
|
||||
gotReq *sdk.UpdateModelRequest
|
||||
gotID string
|
||||
}
|
||||
|
||||
func (f *fakeUpdateSvc) GetModel(_ context.Context, _ string) (*sdk.Model, error) {
|
||||
return f.cur, nil
|
||||
}
|
||||
|
||||
func (f *fakeUpdateSvc) UpdateModel(_ context.Context, id string, req *sdk.UpdateModelRequest) (*sdk.Model, error) {
|
||||
f.gotID = id
|
||||
f.gotReq = req
|
||||
return &sdk.Model{ID: id, Name: req.Name, DisplayName: req.DisplayName, Parameters: req.Parameters, IsDefault: req.IsDefault}, nil
|
||||
}
|
||||
|
||||
func baseModel() *sdk.Model {
|
||||
return &sdk.Model{
|
||||
ID: "mdl_x", Name: "keep-name", DisplayName: "old", Description: "olddesc",
|
||||
Parameters: sdk.ModelParameters{"base_url": "http://old", "api_key": "SECRET-OLD", "provider": "generic"},
|
||||
IsDefault: false,
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelUpdate_SurgicalOverlay: only touched fields change; the rest (name,
|
||||
// description, existing params) round-trip from the fetched baseline.
|
||||
func TestModelUpdate_SurgicalOverlay(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeUpdateSvc{cur: baseModel()}
|
||||
opts := &UpdateOptions{DisplayName: "new-display", flags: modelUpdateFlags{displayName: true}}
|
||||
require.NoError(t, runUpdate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "mdl_x", nil))
|
||||
|
||||
require.NotNil(t, svc.gotReq)
|
||||
assert.Equal(t, "mdl_x", svc.gotID, "id preserved (in-place update)")
|
||||
assert.Equal(t, "new-display", svc.gotReq.DisplayName, "display-name overlaid")
|
||||
assert.Equal(t, "keep-name", svc.gotReq.Name, "untouched name round-trips")
|
||||
assert.Equal(t, "olddesc", svc.gotReq.Description, "untouched description round-trips")
|
||||
assert.Equal(t, "http://old", svc.gotReq.Parameters["base_url"], "untouched params round-trip")
|
||||
assert.Equal(t, "SECRET-OLD", svc.gotReq.Parameters["api_key"], "existing key preserved when not rotating")
|
||||
}
|
||||
|
||||
// TestModelUpdate_RotateKeyAndBaseURL: --api-key-stdin + --base-url overlay the
|
||||
// parameters map; the new key comes from stdin, never argv.
|
||||
func TestModelUpdate_RotateKeyAndBaseURL(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeUpdateSvc{cur: baseModel()}
|
||||
opts := &UpdateOptions{
|
||||
BaseURL: "http://new", APIKeyStdin: true,
|
||||
StdinReader: strings.NewReader("NEW-KEY\n"),
|
||||
flags: modelUpdateFlags{baseURL: true},
|
||||
}
|
||||
require.NoError(t, runUpdate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "mdl_x", nil))
|
||||
assert.Equal(t, "http://new", svc.gotReq.Parameters["base_url"])
|
||||
assert.Equal(t, "NEW-KEY", svc.gotReq.Parameters["api_key"], "rotated key read from stdin")
|
||||
}
|
||||
|
||||
// TestModelUpdate_RequiresAtLeastOneFlag: a bare `model update <id>` is rejected
|
||||
// before any network call, matching agent update.
|
||||
func TestModelUpdate_RequiresAtLeastOneFlag(t *testing.T) {
|
||||
assert.False(t, modelUpdateHasFlag(&UpdateOptions{}))
|
||||
assert.True(t, modelUpdateHasFlag(&UpdateOptions{flags: modelUpdateFlags{displayName: true}}))
|
||||
assert.True(t, modelUpdateHasFlag(&UpdateOptions{APIKeyStdin: true}))
|
||||
assert.True(t, modelUpdateHasFlag(&UpdateOptions{Params: []string{"k=v"}}))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// modelViewFields enumerates the fields surfaced for `--format json` discovery
|
||||
// on `model view`. Includes the nested `parameters` object (the single-record
|
||||
// view is where callers want the full configuration).
|
||||
var modelViewFields = []string{
|
||||
"id", "name", "display_name", "type", "source",
|
||||
"description", "is_default", "parameters", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on.
|
||||
type ViewService interface {
|
||||
GetModel(ctx context.Context, id string) (*sdk.Model, error)
|
||||
}
|
||||
|
||||
// NewCmdView builds `weknora model view <model-id>`.
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <model-id>",
|
||||
Short: "Show a model by ID",
|
||||
Long: `Fetch one model's full record: type, source, default flag, and parameters.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runView(c.Context(), fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, modelViewFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one model's full record by id",
|
||||
RequiredFlags: []string{"<model-id> (positional)"},
|
||||
Examples: []string{"weknora model view model_abc --jq .data.type"},
|
||||
Output: "envelope.data is the Model object (id, name, display_name, type, source, is_default, parameters)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, fopts *cmdutil.FormatOptions, svc ViewService, id string) error {
|
||||
m, err := svc.GetModel(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "get model %q", id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, m, nil)
|
||||
}
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "ID: %s\n", m.ID)
|
||||
if label := modelLabel(*m); label != "" {
|
||||
fmt.Fprintf(w, "NAME: %s\n", label)
|
||||
}
|
||||
fmt.Fprintf(w, "TYPE: %s\n", m.Type)
|
||||
fmt.Fprintf(w, "SOURCE: %s\n", m.Source)
|
||||
if m.IsDefault {
|
||||
fmt.Fprintf(w, "DEFAULT: yes\n")
|
||||
}
|
||||
if m.Description != "" {
|
||||
fmt.Fprintf(w, "DESCRIPTION: %s\n", m.Description)
|
||||
}
|
||||
if m.CreatedAt != "" {
|
||||
fmt.Fprintf(w, "CREATED: %s\n", m.CreatedAt)
|
||||
}
|
||||
if m.UpdatedAt != "" {
|
||||
fmt.Fprintf(w, "UPDATED: %s\n", m.UpdatedAt)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeViewSvc struct {
|
||||
model *sdk.Model
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeViewSvc) GetModel(_ context.Context, _ string) (*sdk.Model, error) {
|
||||
return f.model, f.err
|
||||
}
|
||||
|
||||
func TestModelView_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{model: &sdk.Model{
|
||||
ID: "m1", DisplayName: "GPT-X", Type: sdk.ModelTypeKnowledgeQA,
|
||||
Source: sdk.ModelSourceOpenAI, IsDefault: true,
|
||||
}}
|
||||
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "m1"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"ID:", "m1", "NAME:", "GPT-X", "TYPE:", "KnowledgeQA", "SOURCE:", "openai", "DEFAULT:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("missing %q in:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelView_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{model: &sdk.Model{ID: "m1", Type: sdk.ModelTypeEmbedding}}
|
||||
if err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "m1"); err != nil {
|
||||
t.Fatalf("runView: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Model `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatalf("parse: %v\n%s", err, out.String())
|
||||
}
|
||||
if !env.OK || env.Data.ID != "m1" {
|
||||
t.Errorf("expected ok envelope with id=m1, got %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelView_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runView(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "missing")
|
||||
if err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
if !cmdutil.IsNotFound(err) {
|
||||
t.Errorf("expected resource.not_found, got %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user