diff --git a/go/plugins/compat_oai/ollamacloud/ollamacloud.go b/go/plugins/compat_oai/ollamacloud/ollamacloud.go new file mode 100644 index 0000000000..8393533f91 --- /dev/null +++ b/go/plugins/compat_oai/ollamacloud/ollamacloud.go @@ -0,0 +1,164 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package ollamacloud + +import ( + "context" + "fmt" + "os" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/core/api" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/compat_oai" + "github.com/openai/openai-go/option" +) + +const ( + provider = "ollamacloud" + apiBaseURL = "https://ollama.com" + apiVersion = "v1" +) + +// supportedModels defines a curated set of Ollama Cloud models. +// Model IDs are aligned with https://ollama.com/v1/models. +var supportedModels = map[string]ai.ModelOptions{ + // Large Language Models (text-only) + "gpt-oss:20b": { + Label: "GPT-OSS 20B", + Supports: &compat_oai.BasicText, + Versions: []string{"gpt-oss:20b"}, + }, + "gpt-oss:120b": { + Label: "GPT-OSS 120B", + Supports: &compat_oai.BasicText, + Versions: []string{"gpt-oss:120b"}, + }, + "qwen3-coder:480b": { + Label: "Qwen3 Coder 480B", + Supports: &compat_oai.BasicText, + Versions: []string{"qwen3-coder:480b"}, + }, + "deepseek-v3.1:671b": { + Label: "DeepSeek v3.1 671B", + Supports: &compat_oai.BasicText, + Versions: []string{"deepseek-v3.1:671b"}, + }, + "glm-4.6": { + Label: "GLM-4.6", + Supports: &compat_oai.BasicText, + Versions: []string{"glm-4.6"}, + }, + "minimax-m2": { + Label: "MiniMax M2", + Supports: &compat_oai.BasicText, + Versions: []string{"minimax-m2"}, + }, + "kimi-k2:1t": { + Label: "Kimi K2 1T", + Supports: &compat_oai.BasicText, + Versions: []string{"kimi-k2:1t"}, + }, + "kimi-k2-thinking": { + Label: "Kimi K2 Thinking", + Supports: &compat_oai.BasicText, + Versions: []string{"kimi-k2-thinking"}, + }, + + // Multimodal Models (Vision + Text) + "qwen3-vl:235b-instruct": { + Label: "Qwen3 VL 235B Instruct", + Supports: &compat_oai.Multimodal, + Versions: []string{"qwen3-vl:235b-instruct"}, + }, + "qwen3-vl:235b": { + Label: "Qwen3 VL 235B", + Supports: &compat_oai.Multimodal, + Versions: []string{"qwen3-vl:235b"}, + }, +} + +// OllamaCloud represents the Ollama Cloud plugin +type OllamaCloud struct { + APIKey string + Opts []option.RequestOption + + openAICompatible *compat_oai.OpenAICompatible +} + +// Name implements genkit.Plugin. +func (o *OllamaCloud) Name() string { + return provider +} + +// Init implements genkit.Plugin. +func (o *OllamaCloud) Init(ctx context.Context) []api.Action { + apiKey := o.APIKey + if apiKey == "" { + apiKey = os.Getenv("OLLAMACLOUD_API_KEY") + } + + if apiKey == "" { + panic("ollamacloud plugin initialization failed: API key is required") + } + + if o.openAICompatible == nil { + o.openAICompatible = &compat_oai.OpenAICompatible{} + } + + // Configure OpenAI-compatible client with Ollama Cloud settings + o.openAICompatible.Opts = []option.RequestOption{ + option.WithAPIKey(apiKey), + option.WithBaseURL(fmt.Sprintf("%s/%s", apiBaseURL, apiVersion)), + } + if len(o.Opts) > 0 { + o.openAICompatible.Opts = append(o.openAICompatible.Opts, o.Opts...) + } + + o.openAICompatible.Provider = provider + compatActions := o.openAICompatible.Init(ctx) + + var actions []api.Action + actions = append(actions, compatActions...) + + // Define available models + for model, opts := range supportedModels { + actions = append(actions, o.DefineModel(model, opts).(api.Action)) + } + + return actions +} + +// Model returns the ai.Model with the given name. +func (o *OllamaCloud) Model(g *genkit.Genkit, name string) ai.Model { + return o.openAICompatible.Model(g, api.NewName(provider, name)) +} + +// DefineModel defines a model with the given ID and options. +func (o *OllamaCloud) DefineModel(id string, opts ai.ModelOptions) ai.Model { + return o.openAICompatible.DefineModel(provider, id, opts) +} + +// ListActions implements genkit.Plugin. +func (o *OllamaCloud) ListActions(ctx context.Context) []api.ActionDesc { + return o.openAICompatible.ListActions(ctx) +} + +// ResolveAction implements genkit.Plugin. +func (o *OllamaCloud) ResolveAction(atype api.ActionType, name string) api.Action { + return o.openAICompatible.ResolveAction(atype, name) +} diff --git a/go/plugins/compat_oai/ollamacloud/ollamacloud_live_test.go b/go/plugins/compat_oai/ollamacloud/ollamacloud_live_test.go new file mode 100644 index 0000000000..368079aa83 --- /dev/null +++ b/go/plugins/compat_oai/ollamacloud/ollamacloud_live_test.go @@ -0,0 +1,211 @@ +// Copyright 2025 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +package ollamacloud + +import ( + "context" + "math" + "os" + "strings" + "testing" + + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/openai/openai-go/option" +) + +func TestPlugin(t *testing.T) { + apiKey := os.Getenv("OLLAMACLOUD_API_KEY") + if apiKey == "" { + t.Skip("Skipping test: OLLAMACLOUD_API_KEY environment variable not set") + } + + ctx := context.Background() + ollamaCloud := &OllamaCloud{ + APIKey: apiKey, + Opts: []option.RequestOption{ + option.WithAPIKey(apiKey), + }, + } + + g := genkit.Init(ctx, + genkit.WithDefaultModel("ollamacloud/gpt-oss:20b"), + genkit.WithPlugins(ollamaCloud)) + + gablorkenTool := genkit.DefineTool(g, "gablorken", "use when need to calculate a gablorken", + func(ctx *ai.ToolContext, input struct { + Value float64 + Over float64 + }) (float64, error) { + return math.Pow(input.Value, input.Over), nil + }) + + t.Log("ollamacloud plugin initialized") + + t.Run("basic completion", func(t *testing.T) { + t.Log("generating basic completion response") + resp, err := genkit.Generate(ctx, g, + ai.WithPrompt("What is the capital of France?")) + if err != nil { + t.Fatal("error generating basic completion response: ", err) + } + t.Logf("basic completion response: %+v", resp) + out := resp.Message.Content[0].Text + if !strings.Contains(strings.ToLower(out), "paris") { + t.Errorf("got %q, expecting it to contain 'Paris'", out) + } + // Verify usage statistics are present + if resp.Usage == nil || resp.Usage.TotalTokens == 0 { + t.Error("Expected non-zero usage statistics") + } + }) + + t.Run("streaming", func(t *testing.T) { + var streamedOutput string + chunks := 0 + final, err := genkit.Generate(ctx, g, + ai.WithPrompt("Write a short paragraph about artificial intelligence."), + ai.WithStreaming(func(ctx context.Context, chunk *ai.ModelResponseChunk) error { + chunks++ + for _, content := range chunk.Content { + streamedOutput += content.Text + } + return nil + })) + if err != nil { + t.Fatal(err) + } + // Verify streaming worked + if chunks <= 1 { + t.Error("Expected multiple chunks for streaming") + } + // Verify the final output matches streamed content + finalOutput := "" + for _, content := range final.Message.Content { + finalOutput += content.Text + } + if streamedOutput != finalOutput { + t.Errorf("Streaming output doesn't match final output\nStreamed: %s\nFinal: %s", + streamedOutput, finalOutput) + } + t.Logf("streaming response: %+v", finalOutput) + }) + + t.Run("media part", func(t *testing.T) { + image := "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAHIAAABUAQMAAABk5vEVAAAABlBMVEX///8AAABVwtN+" + + "AAAAI0lEQVR4nGNgGHaA/z8UHIDwOWASDqP8Uf7w56On/1FAQwAAVM0exw1hqwkAAAAASUVORK5CYII=" + resp, err := genkit.Generate(ctx, g, + ai.WithModelName("ollamacloud/qwen3-vl:235b-instruct"), + ai.WithMessages( + ai.NewUserMessage( + ai.NewMediaPart("image/png", image), + ai.NewTextPart("Is there a rectangle in the picture? Yes or not."), + ), + ), + ) + if err != nil { + t.Fatal(err) + } + text := resp.Message.Content[0].Text + if !strings.Contains(strings.ToLower(text), "yes") { + t.Errorf("got %q, expecting it to contain 'yes'", text) + } + }) + + t.Run("system message", func(t *testing.T) { + resp, err := genkit.Generate(ctx, g, + ai.WithPrompt("What are you?"), + ai.WithSystem("You are a helpful math tutor who loves numbers.")) + if err != nil { + t.Fatal(err) + } + out := resp.Message.Content[0].Text + if !strings.Contains(strings.ToLower(out), "math") { + t.Errorf("got %q, expecting response to mention being a math tutor", out) + } + t.Logf("system message response: %+v", out) + }) + + t.Run("tool usage with basic completion", func(t *testing.T) { + resp, err := genkit.Generate(ctx, g, + ai.WithModelName("ollamacloud/qwen3-coder:480b"), + ai.WithPrompt("Use the gablorken tool to calculate the gablorken of 2 over 3. Set Value=2 and Over=3 as numbers (not strings) and answer with the numeric result."), + ai.WithTools(gablorkenTool)) + if err != nil { + t.Fatal(err) + } + out := resp.Message.Content[0].Text + const want = "8" + if !strings.Contains(out, want) { + t.Errorf("got %q, expecting it to contain %q", out, want) + } + t.Logf("tool usage with basic completion response: %+v", out) + }) + + t.Run("tool usage with streaming", func(t *testing.T) { + var streamedOutput string + chunks := 0 + final, err := genkit.Generate(ctx, g, + ai.WithModelName("ollamacloud/qwen3-coder:480b"), + ai.WithPrompt("Use the gablorken tool to calculate the gablorken of 2 over 3. Set Value=2 and Over=3 as numbers (not strings) and answer with the numeric result."), + ai.WithTools(gablorkenTool), + ai.WithStreaming(func(ctx context.Context, chunk *ai.ModelResponseChunk) error { + chunks++ + for _, content := range chunk.Content { + streamedOutput += content.Text + } + return nil + })) + if err != nil { + t.Fatal(err) + } + // Verify streaming worked + if chunks <= 1 { + t.Error("Expected multiple chunks for streaming") + } + // Verify the final output matches streamed content + finalOutput := "" + for _, content := range final.Message.Content { + finalOutput += content.Text + } + if streamedOutput != finalOutput { + t.Errorf("Streaming output doesn't match final output\nStreamed: %s\nFinal: %s", + streamedOutput, finalOutput) + } + const want = "8" + if !strings.Contains(finalOutput, want) { + t.Errorf("got %q, expecting it to contain %q", finalOutput, want) + } + t.Logf("tool usage with streaming response: %+v", finalOutput) + }) + + t.Run("invalid config type", func(t *testing.T) { + // Try to use a string as config instead of *openai.ChatCompletionNewParams + config := "not a config" + _, err := genkit.Generate(ctx, g, + ai.WithPrompt("Write a short sentence about artificial intelligence."), + ai.WithConfig(config), + ) + if err == nil { + t.Fatal("expected error for invalid config type") + } + if !strings.Contains(err.Error(), "unexpected config type: string") { + t.Errorf("got error %q, want error containing 'unexpected config type: string'", err.Error()) + } + t.Logf("invalid config type error: %v", err) + }) +} diff --git a/go/plugins/compat_oai/ollamacloud/readme.md b/go/plugins/compat_oai/ollamacloud/readme.md new file mode 100644 index 0000000000..f6ae23a82d --- /dev/null +++ b/go/plugins/compat_oai/ollamacloud/readme.md @@ -0,0 +1,184 @@ +# Ollama Cloud Plugin + +This plugin provides a simple interface for using Ollama Cloud services through OpenAI-compatible API. + +## Supported Models + +The plugin supports the following Ollama Cloud models: + +### Large Language Models (Text) +- **GPT-OSS 20B** - `gpt-oss:20b` +- **GPT-OSS 120B** - `gpt-oss:120b` +- **Qwen3 Coder 480B** - `qwen3-coder:480b` +- **DeepSeek v3.1 671B** - `deepseek-v3.1:671b` +- **GLM-4.6** - `glm-4.6` +- **MiniMax M2** - `minimax-m2` +- **Kimi K2 1T** - `kimi-k2:1t` +- **Kimi K2 Thinking** - `kimi-k2-thinking` + +### Multimodal Models (Vision + Text) +- **Qwen3 VL 235B Instruct** - `qwen3-vl:235b-instruct` - Vision-language model (images + text, tools) +- **Qwen3 VL 235B** - `qwen3-vl:235b` - Vision-language model (images + text, tools) + +## Prerequisites + +- Go installed on your system +- An Ollama Cloud API key + +## Usage + +Here's a simple example of how to use the Ollama Cloud plugin: + +```go +import ( + "context" + "os" + "github.com/firebase/genkit/go/ai" + "github.com/firebase/genkit/go/genkit" + "github.com/firebase/genkit/go/plugins/compat_oai/ollamacloud" + "github.com/openai/openai-go/option" +) + +// Initialize the Ollama Cloud plugin with your API key +plugin := &ollamacloud.OllamaCloud{ + APIKey: "your-ollamacloud-api-key", // or use the OLLAMACLOUD_API_KEY environment variable + Opts: []option.RequestOption{ + option.WithAPIKey("your-ollamacloud-api-key"), + }, +} + +// Initialize Genkit with the OllamaCloud plugin +g := genkit.Init(ctx, + genkit.WithDefaultModel("ollamacloud/gpt-oss:20b"), + genkit.WithPlugins(plugin)) + +// Basic text generation +resp, err := genkit.Generate(ctx, g, + ai.WithPromptText("Explain quantum computing in simple terms.")) + +// Use a multimodal model (image + text) +resp, err := genkit.Generate(ctx, g, + ai.WithModelName("ollamacloud/qwen3-vl:235b-instruct"), + ai.WithMessages( + ai.NewUserMessage( + ai.NewMediaPart("image/png", imageData), + ai.NewTextPart("What do you see in this image?"), + ), + )) + +// Use with tools +calculator := genkit.DefineTool(g, "calculator", "simple calculator", + func(ctx *ai.ToolContext, input struct { + Operation string `json:"operation"` // "add", "subtract", "multiply", "divide" + A float64 `json:"a"` + B float64 `json:"b"` + }) (float64, error) { + switch input.Operation { + case "add": return input.A + input.B, nil + case "subtract": return input.A - input.B, nil + case "multiply": return input.A * input.B, nil + case "divide": + if input.B == 0 { return 0, fmt.Errorf("division by zero") } + return input.A / input.B, nil + } + return 0, fmt.Errorf("unknown operation") + }) + +resp, err := genkit.Generate(ctx, g, + ai.WithPromptText("What is 15 * 23?"), + ai.WithTools(calculator)) + +// Streaming responses +resp, err := genkit.Generate(ctx, g, + ai.WithPromptText("Write a short story about space exploration."), + ai.WithStreaming(func(ctx context.Context, chunk *ai.ModelResponseChunk) error { + for _, content := range chunk.Content { + fmt.Print(content.Text) + } + return nil + })) +``` + +## Environment Variables + +- `OLLAMACLOUD_API_KEY`: Your Ollama Cloud API key (required) + +The base URL defaults to `https://ollama.com/v1`. To override it (for example, when using a proxy), +pass a custom `option.WithBaseURL(...)` value via the plugin's `Opts` field. + +## Running Tests + +First, set your Ollama Cloud API key as an environment variable: + +```bash +export OLLAMACLOUD_API_KEY= +``` + +### Running All Tests + +To run all tests in the directory: + +```bash +go test -v . +``` + +### Running Tests from Specific Files + +To run tests from a specific file: + +```bash +# Run only the main plugin tests +go test -run "^TestPlugin" +``` + +### Running Individual Tests + +To run a specific test case: + +```bash +# Run only the basic completion test +go test -run "TestPlugin/basic completion" + +# Run only the streaming test +go test -run "TestPlugin/streaming" + +# Run only the tool usage test +go test -run "TestPlugin/tool usage" + +# Run only the multimodal test +go test -run "TestPlugin/media part" +``` + +### Test Output Verbosity + +Add the `-v` flag for verbose output: + +```bash +go test -v -run "TestPlugin/streaming" +``` + +## Features + +- ✅ **OpenAI-compatible API**: Uses the standard OpenAI SDK +- ✅ **Streaming responses**: Real-time streaming for better UX +- ✅ **Tool calling**: Function calling support for interactive applications +- ✅ **Multimodal support**: Vision models can process images + text +- ✅ **Multiple models**: Access to various open-source models +- ✅ **Genkit integration**: Seamless integration with Genkit framework + +## Troubleshooting + +### Common Issues + +1. **API Key Error**: Make sure `OLLAMA_API_KEY` is set correctly +2. **Network Issues**: Check your internet connection and firewall settings +3. **Model Not Found**: Verify the model name is supported by Ollama Cloud +4. **Rate Limiting**: Check if you've hit API rate limits + +### Error Messages + +- `"ollamacloud plugin initialization failed: API key is required"`: Set your API key +- `"unexpected config type: string"`: Use proper OpenAI config types +- Network errors: Check API endpoint and network connectivity + +Note: All live tests require the OLLAMA_API_KEY environment variable to be set. Tests will be skipped if the API key is not provided.