Skip to content

Commit 2f8c2b6

Browse files
feat: add textInference command for LLM text generation
Supports sync delivery with messages-based API, system prompts, temperature/topP/topK sampling, and pipe-friendly text output. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8691392 commit 2f8c2b6

5 files changed

Lines changed: 357 additions & 0 deletions

File tree

cmd/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func init() {
5050
rootCmd.AddCommand(imageInferenceCmd)
5151
rootCmd.AddCommand(videoInferenceCmd)
5252
rootCmd.AddCommand(audioInferenceCmd)
53+
rootCmd.AddCommand(textInferenceCmd)
5354
rootCmd.AddCommand(modelSearchCmd)
5455
rootCmd.AddCommand(accountCmd)
5556
rootCmd.AddCommand(configCmd)

cmd/text_inference.go

Lines changed: 199 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
package cmd
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"os"
8+
"time"
9+
10+
"github.com/briandowns/spinner"
11+
"github.com/runware/runware-cli/internal/api"
12+
"github.com/runware/runware-cli/internal/config"
13+
"github.com/runware/runware-cli/internal/output"
14+
"github.com/spf13/cobra"
15+
)
16+
17+
var textInferenceCmd = &cobra.Command{
18+
Use: "textInference [message]",
19+
Short: "Generate text using a language model",
20+
Long: `Send a message to a language model and get a text response.
21+
22+
Examples:
23+
runware textInference "What is the capital of France?"
24+
runware textInference "Explain quantum computing" --model runware:qwen3-thinking@1 --max-tokens 500
25+
runware textInference "Write a haiku about coding" --system "You are a poet" --temperature 0.8
26+
runware textInference "List 3 facts about Mars" --output-format json`,
27+
Args: cobra.ExactArgs(1),
28+
RunE: runTextInference,
29+
}
30+
31+
func init() {
32+
f := textInferenceCmd.Flags()
33+
f.String("model", "", "Model identifier (e.g. runware:qwen3-thinking@1)")
34+
f.String("system", "", "System prompt")
35+
f.Int("max-tokens", 0, "Maximum tokens in response (1-128000)")
36+
f.Float64("temperature", 0, "Sampling temperature (0-2)")
37+
f.Float64("top-p", 0, "Nucleus sampling parameter (0-1)")
38+
f.Int("top-k", 0, "Top-k sampling parameter (1-100)")
39+
f.Int64("seed", 0, "Random seed for reproducibility")
40+
f.StringSlice("stop", nil, "Stop sequences (max 5)")
41+
f.Int("count", 1, "Number of results to generate (1-4)")
42+
f.String("output-format", "", "LLM output format: text or json")
43+
f.Bool("include-cost", false, "Include cost info in response")
44+
f.String("preset", "", "Named preset to apply")
45+
f.Bool("dry-run", false, "Print the API request without executing")
46+
47+
_ = textInferenceCmd.RegisterFlagCompletionFunc("output-format", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
48+
return []string{"text", "json"}, cobra.ShellCompDirectiveNoFileComp
49+
})
50+
51+
_ = textInferenceCmd.RegisterFlagCompletionFunc("preset", completePresetNames)
52+
}
53+
54+
func runTextInference(cmd *cobra.Command, args []string) error {
55+
key := config.GetAPIKey()
56+
if key == "" {
57+
output.Error("No API key configured. Run 'runware auth login' to authenticate.")
58+
return api.ErrNoAPIKey
59+
}
60+
61+
cfg := config.Get()
62+
message := args[0]
63+
64+
model := cfg.Defaults.Model
65+
66+
// Apply preset if specified
67+
presetName, _ := cmd.Flags().GetString("preset")
68+
if presetName != "" {
69+
preset := config.GetPreset(presetName)
70+
if preset == nil {
71+
return fmt.Errorf("preset '%s' not found", presetName)
72+
}
73+
if preset.Model != "" {
74+
model = preset.Model
75+
}
76+
}
77+
78+
// Override with explicit CLI flags
79+
if cmd.Flags().Changed("model") {
80+
model, _ = cmd.Flags().GetString("model")
81+
}
82+
83+
systemPrompt, _ := cmd.Flags().GetString("system")
84+
maxTokens, _ := cmd.Flags().GetInt("max-tokens")
85+
temperature, _ := cmd.Flags().GetFloat64("temperature")
86+
topP, _ := cmd.Flags().GetFloat64("top-p")
87+
topK, _ := cmd.Flags().GetInt("top-k")
88+
seed, _ := cmd.Flags().GetInt64("seed")
89+
stopSequences, _ := cmd.Flags().GetStringSlice("stop")
90+
count, _ := cmd.Flags().GetInt("count")
91+
outputFmt, _ := cmd.Flags().GetString("output-format")
92+
includeCost, _ := cmd.Flags().GetBool("include-cost")
93+
dryRun, _ := cmd.Flags().GetBool("dry-run")
94+
95+
// Build request
96+
req := &api.TextInferenceRequest{
97+
TaskType: "textInference",
98+
TaskUUID: api.NewUUID(),
99+
Model: model,
100+
Messages: []api.Message{
101+
{Role: "user", Content: message},
102+
},
103+
IncludeCost: includeCost,
104+
}
105+
106+
if systemPrompt != "" {
107+
req.SystemPrompt = systemPrompt
108+
}
109+
if maxTokens > 0 {
110+
req.MaxTokens = maxTokens
111+
}
112+
if cmd.Flags().Changed("temperature") {
113+
req.Temperature = temperature
114+
}
115+
if cmd.Flags().Changed("top-p") {
116+
req.TopP = topP
117+
}
118+
if topK > 0 {
119+
req.TopK = topK
120+
}
121+
if seed > 0 {
122+
req.Seed = seed
123+
}
124+
if len(stopSequences) > 0 {
125+
req.StopSequences = stopSequences
126+
}
127+
if count > 1 {
128+
req.NumberResults = count
129+
}
130+
if outputFmt != "" {
131+
req.OutputFormat = outputFmt
132+
}
133+
134+
// Dry run
135+
if dryRun {
136+
data, _ := json.MarshalIndent([]interface{}{req}, "", " ")
137+
fmt.Println(string(data))
138+
return nil
139+
}
140+
141+
// Submit
142+
var s *spinner.Spinner
143+
if output.IsTTY() {
144+
s = spinner.New(spinner.CharSets[14], 100*time.Millisecond, spinner.WithWriter(os.Stderr))
145+
s.Suffix = " Generating text..."
146+
s.Start()
147+
}
148+
149+
client := api.NewClient(key, config.GetBaseURL(), flagVerbose)
150+
results, err := client.TextInference(context.Background(), req)
151+
152+
if s != nil {
153+
s.Stop()
154+
}
155+
156+
if err != nil {
157+
if api.IsAuthError(err) {
158+
output.Error("Authentication failed. Run 'runware auth login' to set your API key.")
159+
return err
160+
}
161+
return err
162+
}
163+
164+
if len(results) == 0 {
165+
output.Error("No results returned")
166+
return fmt.Errorf("empty response from text inference")
167+
}
168+
169+
// JSON/YAML output
170+
format := output.ParseFormat(getFormat())
171+
if format != output.FormatTable {
172+
return output.Print(format, results, nil, nil)
173+
}
174+
175+
// Table: single result without cost — print text directly (pipe-friendly)
176+
if len(results) == 1 && !includeCost {
177+
fmt.Println(results[0].Text)
178+
return nil
179+
}
180+
181+
// Multiple results or cost requested — use table
182+
headers := []interface{}{"#", "Text"}
183+
if includeCost {
184+
headers = append(headers, "Cost")
185+
}
186+
var rows [][]interface{}
187+
for i, r := range results {
188+
text := r.Text
189+
if len(text) > 100 {
190+
text = text[:100] + "..."
191+
}
192+
row := []interface{}{i + 1, text}
193+
if includeCost {
194+
row = append(row, fmt.Sprintf("%.6f", r.Cost))
195+
}
196+
rows = append(rows, row)
197+
}
198+
return output.Print(format, results, headers, rows)
199+
}

internal/api/client.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type Client interface {
1818
ImageInference(ctx context.Context, req *ImageInferenceRequest) ([]ImageInferenceResult, error)
1919
VideoInference(ctx context.Context, req *VideoInferenceRequest) ([]VideoInferenceResult, error)
2020
AudioInference(ctx context.Context, req *AudioInferenceRequest) ([]AudioInferenceResult, error)
21+
TextInference(ctx context.Context, req *TextInferenceRequest) ([]TextInferenceResult, error)
2122
GetResponse(ctx context.Context, taskUUID string) ([]json.RawMessage, error)
2223
AccountDetails(ctx context.Context) (*AccountResult, error)
2324
ModelSearch(ctx context.Context, req *ModelSearchRequest) (*ModelSearchResponse, error)
@@ -245,6 +246,32 @@ func (c *RestClient) AudioInference(ctx context.Context, req *AudioInferenceRequ
245246
return results, nil
246247
}
247248

249+
// TextInference runs a text inference task.
250+
func (c *RestClient) TextInference(ctx context.Context, req *TextInferenceRequest) ([]TextInferenceResult, error) {
251+
req.TaskType = "textInference"
252+
if req.TaskUUID == "" {
253+
req.TaskUUID = NewUUID()
254+
}
255+
256+
tasks := []interface{}{req}
257+
258+
resp, err := c.do(ctx, tasks)
259+
if err != nil {
260+
return nil, err
261+
}
262+
263+
var results []TextInferenceResult
264+
for _, raw := range resp.Data {
265+
var r TextInferenceResult
266+
if err := json.Unmarshal(raw, &r); err != nil {
267+
return nil, fmt.Errorf("failed to parse text result: %w", err)
268+
}
269+
results = append(results, r)
270+
}
271+
272+
return results, nil
273+
}
274+
248275
// GetResponse polls for async task results.
249276
func (c *RestClient) GetResponse(ctx context.Context, taskUUID string) ([]json.RawMessage, error) {
250277
tasks := []interface{}{

internal/api/types.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,47 @@ type AudioInferenceResult struct {
176176
Cost float64 `json:"cost,omitempty"`
177177
}
178178

179+
// Message represents a single message in a text inference conversation.
180+
type Message struct {
181+
Role string `json:"role"`
182+
Content string `json:"content"`
183+
}
184+
185+
// TextInferenceRequest contains fields for the textInference task type.
186+
type TextInferenceRequest struct {
187+
TaskType string `json:"taskType"`
188+
TaskUUID string `json:"taskUUID"`
189+
Model string `json:"model"`
190+
Messages []Message `json:"messages"`
191+
MaxTokens int `json:"maxTokens,omitempty"`
192+
Temperature float64 `json:"temperature,omitempty"`
193+
TopP float64 `json:"topP,omitempty"`
194+
TopK int `json:"topK,omitempty"`
195+
Seed int64 `json:"seed,omitempty"`
196+
StopSequences []string `json:"stopSequences,omitempty"`
197+
NumberResults int `json:"numberResults,omitempty"`
198+
SystemPrompt string `json:"systemPrompt,omitempty"`
199+
OutputFormat string `json:"outputFormat,omitempty"`
200+
IncludeCost bool `json:"includeCost,omitempty"`
201+
}
202+
203+
// TextInferenceUsage contains token usage statistics.
204+
type TextInferenceUsage struct {
205+
InputTokens int `json:"inputTokens,omitempty"`
206+
OutputTokens int `json:"outputTokens,omitempty"`
207+
TotalTokens int `json:"totalTokens,omitempty"`
208+
}
209+
210+
// TextInferenceResult is a single text result from the API.
211+
type TextInferenceResult struct {
212+
TaskType string `json:"taskType"`
213+
TaskUUID string `json:"taskUUID"`
214+
Text string `json:"text"`
215+
FinishReason string `json:"finishReason,omitempty"`
216+
Usage TextInferenceUsage `json:"usage,omitempty"`
217+
Cost float64 `json:"cost,omitempty"`
218+
}
219+
179220
// ModelSearchRequest contains fields for the modelSearch task type.
180221
type ModelSearchRequest struct {
181222
TaskType string `json:"taskType"`

internal/api/types_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -652,6 +652,95 @@ func TestModelSearchResponseEmpty(t *testing.T) {
652652
}
653653
}
654654

655+
func TestTextInferenceRequestJSON(t *testing.T) {
656+
req := &TextInferenceRequest{
657+
TaskType: "textInference",
658+
TaskUUID: "test-uuid-1234",
659+
Model: "runware:qwen3-thinking@1",
660+
Messages: []Message{
661+
{Role: "user", Content: "What is Go?"},
662+
},
663+
MaxTokens: 500,
664+
Temperature: 0.8,
665+
SystemPrompt: "You are helpful",
666+
IncludeCost: true,
667+
}
668+
669+
data, err := json.Marshal(req)
670+
if err != nil {
671+
t.Fatalf("Marshal error: %v", err)
672+
}
673+
674+
var parsed map[string]interface{}
675+
if err := json.Unmarshal(data, &parsed); err != nil {
676+
t.Fatalf("Unmarshal error: %v", err)
677+
}
678+
679+
expectedFields := []string{"taskType", "taskUUID", "model", "messages", "maxTokens", "temperature", "systemPrompt", "includeCost"}
680+
for _, field := range expectedFields {
681+
if _, ok := parsed[field]; !ok {
682+
t.Errorf("missing expected field %q in JSON output", field)
683+
}
684+
}
685+
686+
// Verify omitempty works for optional fields
687+
omittedFields := []string{"topP", "topK", "seed", "stopSequences", "numberResults", "outputFormat"}
688+
for _, field := range omittedFields {
689+
if _, ok := parsed[field]; ok {
690+
t.Errorf("%s should be omitted when zero/empty", field)
691+
}
692+
}
693+
694+
// Verify messages structure
695+
msgs, ok := parsed["messages"].([]interface{})
696+
if !ok {
697+
t.Fatal("messages is not an array")
698+
}
699+
if len(msgs) != 1 {
700+
t.Fatalf("expected 1 message, got %d", len(msgs))
701+
}
702+
msg := msgs[0].(map[string]interface{})
703+
if msg["role"] != "user" {
704+
t.Errorf("message role = %q, want %q", msg["role"], "user")
705+
}
706+
}
707+
708+
func TestTextInferenceResultJSON(t *testing.T) {
709+
jsonData := `{
710+
"taskType": "textInference",
711+
"taskUUID": "abc-123",
712+
"text": "Go is a programming language designed at Google.",
713+
"finishReason": "stop",
714+
"usage": {
715+
"inputTokens": 10,
716+
"outputTokens": 25,
717+
"totalTokens": 35
718+
},
719+
"cost": 0.000123
720+
}`
721+
722+
var result TextInferenceResult
723+
if err := json.Unmarshal([]byte(jsonData), &result); err != nil {
724+
t.Fatalf("Unmarshal error: %v", err)
725+
}
726+
727+
if result.TaskType != "textInference" {
728+
t.Errorf("taskType = %q, want %q", result.TaskType, "textInference")
729+
}
730+
if result.Text == "" {
731+
t.Error("text is empty")
732+
}
733+
if result.FinishReason != "stop" {
734+
t.Errorf("finishReason = %q, want %q", result.FinishReason, "stop")
735+
}
736+
if result.Usage.TotalTokens != 35 {
737+
t.Errorf("totalTokens = %d, want %d", result.Usage.TotalTokens, 35)
738+
}
739+
if result.Cost != 0.000123 {
740+
t.Errorf("cost = %f, want %f", result.Cost, 0.000123)
741+
}
742+
}
743+
655744
func TestNewUUID(t *testing.T) {
656745
id1 := NewUUID()
657746
id2 := NewUUID()

0 commit comments

Comments
 (0)