From 1dbaaa8080ce95532a47c1b3779557becba17f9c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 12:07:48 +0000 Subject: [PATCH] Add Kimi AI filter plugin Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012batzHd21Na8eJFgc1x2hY --- doc/AI_TUTORIAL.md | 11 ++ doc/DEPLOYMENT.md | 2 +- doc/PLUGINS.md | 43 ++++- doc/VERSIONS | 2 + plugins/filter/kimi.rb | 216 ++++++++++++++++++++++ spec/plugins/filter/kimi_spec.rb | 306 +++++++++++++++++++++++++++++++ 6 files changed, 577 insertions(+), 3 deletions(-) create mode 100644 plugins/filter/kimi.rb create mode 100644 spec/plugins/filter/kimi_spec.rb diff --git a/doc/AI_TUTORIAL.md b/doc/AI_TUTORIAL.md index 5a858be..f36ee49 100644 --- a/doc/AI_TUTORIAL.md +++ b/doc/AI_TUTORIAL.md @@ -419,6 +419,17 @@ in a Recipe they are interchangeable at the same position: interval: 2 ``` +```yaml + - module: FilterKimi + config: + token: YOUR_KIMI_API_KEY + model: kimi-k3 + prompt: | + 以下の記事群を一つのダイジェストとして日本語で要約してください。 + retry: 2 + interval: 2 +``` + Nothing before or after the swapped entry changes. `token`, `model` and `prompt` are shared required settings of the AI filters; `max_tokens` exists only for Claude because that API requires it. Model names move with the services, so take them from the provider diff --git a/doc/DEPLOYMENT.md b/doc/DEPLOYMENT.md index 08e4a4c..28cb278 100644 --- a/doc/DEPLOYMENT.md +++ b/doc/DEPLOYMENT.md @@ -475,7 +475,7 @@ bundle. `plugins` is every group in the first block at once. | `PublishInstapaper` | an Instapaper account; no gem | — | — | | `PublishEject` | the `eject` or `drutil` command | — | — | | `NotifyIkachan` | an `ikachan` gateway you run | — | — | -| `FilterOpenAI`, `FilterClaude`, `FilterGemini`, `FilterSakuraAI` | an account and an API token with that one service; no gem | — | — | +| `FilterOpenAI`, `FilterClaude`, `FilterGemini`, `FilterSakuraAI`, `FilterKimi` | an account and an API token with that one service; no gem | — | — | | `PublishHatenaBookmark` | the current Hatena API, which it does not speak | — | — | Membership of the aggregate `plugins` group is defined in `Gemfile`. It diff --git a/doc/PLUGINS.md b/doc/PLUGINS.md index 57d1a11..52d7280 100644 --- a/doc/PLUGINS.md +++ b/doc/PLUGINS.md @@ -1373,6 +1373,45 @@ Recipe that says where the text goes for a Recipe that does not. interval: 2 ``` +#### FilterKimi — **Supported (external)** + +`filter/kimi.rb`. Sends each item's description to Moonshot AI's Kimi, +`https://api.moonshot.ai/v1/chat/completions`, and replaces it with the +answer. The token is a bearer token, and the request is the chat completions +form: the prompt as a `system` message, the description as a `user` message. + +| Key | Type | Meaning | +| --- | --- | --- | +| `token` | string | Kimi API key. Required. | +| `model` | string | Model name, as Moonshot names it (e.g. `kimi-k3`). Required. | +| `prompt` | string | The instruction, sent as the `system` message. Required. | +| `retry` | integer | Attempts after a failure. Default `0`. | +| `interval` | integer | Seconds between attempts. Default `0`. | + +**This interface is OpenAI-compatible, and this is still its own plugin.** It is +a different service: a different endpoint, a different account, a different set +of models, its own limits and its own errors, any of which may move without +OpenAI moving. Folding it into `FilterOpenAI` behind a setting would trade a +Recipe that says where the text goes for a Recipe that does not, the same +reasoning that already keeps Sakura AI in a plugin of its own. + +Kimi may return a `reasoning_content` alongside the answer's `content`. That +reasoning is never read, logged or written anywhere; only `content` is used, +and only once `finish_reason` is `"stop"` -- any other value, or a missing or +empty `content`, is an error rather than an empty description. + +```yaml + - module: FilterKimi + config: + token: YOUR_KIMI_API_KEY + model: kimi-k3 + prompt: | + 以下の記事群について、個別記事の要約を羅列するのではなく、 + 全体を一つのダイジェストとして日本語で要約してください。 + retry: 2 + interval: 2 +``` + A digest, end to end: find the articles, drop the ones already seen, fetch their bodies, strip the markup, join them, ask once, write the answer out. @@ -1414,8 +1453,8 @@ plugins: ``` Changing service is changing the one entry: `FilterSakuraAI` for -`FilterOpenAI`, `FilterClaude` or `FilterGemini`, with that plugin's own -settings. Nothing before or after it changes. +`FilterOpenAI`, `FilterClaude`, `FilterGemini` or `FilterKimi`, with that +plugin's own settings. Nothing before or after it changes. ### 6.4 Store diff --git a/doc/VERSIONS b/doc/VERSIONS index 6eceb56..7b50aa7 100644 --- a/doc/VERSIONS +++ b/doc/VERSIONS @@ -3,6 +3,8 @@ automaticruby Repository Version History v26.09 (Release Date: TBD) -------------------------- +- Add FilterKimi for the Kimi API, using its OpenAI-compatible Chat Completions + interface. - Add FilterLimit, FilterBatch, FilterPresent, and per-fetch interval handling; make doc/PLUGINS.md section 6 the plugin catalogue's source of truth. - Harden the framework's execution boundary: fix CLI contract drift, distinguish diff --git a/plugins/filter/kimi.rb b/plugins/filter/kimi.rb new file mode 100644 index 0000000..f39c5de --- /dev/null +++ b/plugins/filter/kimi.rb @@ -0,0 +1,216 @@ +# -*- coding: utf-8 -*- +# Name:: Automatic::Plugin::Filter::Kimi +# Description:: Replace each item's description with what the Kimi API answers. +# Author: id774 (More info: https://id774.net) +# Source Code:: https://github.com/id774/automaticruby +# License:: The GPL version 3, or LGPL version 3 (Dual License). +# Contact:: idnanashi@gmail.com +# Created:: Sep 11, 2026 +# Updated:: Sep 11, 2026 +# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. +# +# One transformation: the item's description goes to the Kimi API under the +# Recipe's prompt, and the answer becomes the item's description. What that +# transformation is -- a summary, a translation, an extraction, a +# classification -- is the prompt's business, not this plugin's. +# +# Moonshot AI's Kimi offers an OpenAI-compatible chat completions interface, +# and this plugin is still its own rather than a mode of FilterOpenAI. It is a +# different service: a different endpoint, a different account, a different +# set of models, its own limits and its own errors, and any of those may move +# without OpenAI moving. A Recipe naming FilterKimi says which service the +# text is sent to, which a `provider:` setting would not. +# +# Kimi may return a `reasoning_content` alongside the answer's own `content`. +# That reasoning is never read, logged or written anywhere by this plugin -- +# only `content` is the answer, and only once `finish_reason` says the model +# is done. +# +# @see https://platform.moonshot.ai/docs/api/chat + +module Automatic::Plugin + class FilterKimi + require 'json' + require 'net/http' + require 'openssl' + require 'uri' + + # The one endpoint the service publishes for this. It is not a setting: an + # operator has no version of this plugin that talks to a different host, + # and a setting for it would be a way to send the token somewhere else. + ENDPOINT = URI('https://api.moonshot.ai/v1/chat/completions') + + OPEN_TIMEOUT = 10 + + # Generous, and bounded. A model given several articles thinks for a while; + # an unattended run that waits forever is the failure this exists against. + READ_TIMEOUT = 300 + + # A failure that another attempt will not get past: a setting that is + # wrong, a request the service refuses, an answer this plugin cannot read. + class Error < StandardError; end + + # A failure that another attempt may get past: the network, a rate limit, a + # server error. + class TemporaryError < StandardError; end + + def initialize(config, pipeline = []) + @config = config || {} + @pipeline = pipeline + end + + # Replaces each item's description with the answer. Nothing else about an + # item is touched, and the feeds and their items arrive and leave in the + # same number and the same order. + def run + validate_settings + + @pipeline.each { |feeds| + next if feeds.nil? + + feeds.items.each { |item| transform(item) } + } + @pipeline + end + + private + + # Checked before the first request, because a Recipe this plugin cannot + # carry out is the operator's mistake and will be the same mistake on every + # item. The token is never named in a message. + def validate_settings + raise ArgumentError, 'FilterKimi needs a token' if token.empty? + raise ArgumentError, 'FilterKimi needs a model' if model.empty? + raise ArgumentError, 'FilterKimi needs a prompt' if prompt.empty? + end + + def token + @config['token'].to_s + end + + def model + @config['model'].to_s.strip + end + + def prompt + @config['prompt'].to_s.strip + end + + def transform(item) + text = item.description.to_s + if text.strip.empty? + Automatic::Log.puts('warn', "FilterKimi: nothing to send for #{item.link}") + return + end + + Automatic::Log.puts('info', "FilterKimi: asking #{model} about #{item.link}") + item.description = answer(text) + end + + # The retry shape of doc/PLUGINS.md section 3.6, applied only to what + # retrying can help. A missing setting, a refused request or an answer in a + # shape this plugin cannot read is raised at once: trying again would fail + # the same way, more slowly. + def answer(text) + retries = 0 + retry_max = @config['retry'].to_i + begin + completion(text) + rescue TemporaryError => e + retries += 1 + Automatic::Log.puts('error', "ErrorCount: #{retries}, FilterKimi: #{e.message}") + if retries <= retry_max + sleep(@config['interval'].to_i) + retry + end + raise Error, "FilterKimi gave up after #{retries} attempts: #{e.message}" + end + end + + def completion(text) + # The prompt is the system turn and the description is the user turn it + # is applied to. They are separate messages, so that what an article says + # is never read as an instruction to this plugin or to the model. Nothing + # else -- no sampling or tool-use parameter -- is sent. + body = { + 'model' => model, + 'messages' => [ + { 'role' => 'system', 'content' => prompt }, + { 'role' => 'user', 'content' => text } + ] + } + content(post(JSON.generate(body))) + end + + def post(body) + request = Net::HTTP::Post.new(ENDPOINT) + request['Authorization'] = "Bearer #{token}" + request['Content-Type'] = 'application/json' + request.body = body + + # TLS with the certificate verified, which is Net::HTTP's own default and + # is named here because it is not a thing to be turned off. + Net::HTTP.start(ENDPOINT.host, ENDPOINT.port, + use_ssl: true, + verify_mode: OpenSSL::SSL::VERIFY_PEER, + open_timeout: OPEN_TIMEOUT, + read_timeout: READ_TIMEOUT) { |http| http.request(request) } + rescue Timeout::Error, SystemCallError, SocketError, IOError, + OpenSSL::SSL::SSLError, Net::HTTPBadResponse => e + raise TemporaryError, "the request to Kimi failed: #{e.message}" + end + + def content(response) + case response + when Net::HTTPSuccess + answer_text(parse(response.body)) + when Net::HTTPTooManyRequests, Net::HTTPServerError + raise TemporaryError, "Kimi answered #{response.code}: #{reason(response)}" + else + raise Error, "Kimi answered #{response.code}: #{reason(response)}" + end + end + + def parse(body) + JSON.parse(body.to_s) + rescue JSON::ParserError => e + raise Error, "Kimi answered with something that is not JSON: #{e.message}" + end + + # The first choice's message content, once the model says it is actually + # done. `reasoning_content`, when Kimi sends one alongside `content`, is + # never read here: it is not the answer, and it never becomes one. An + # answer this plugin cannot find is an error and not an empty description: + # a Recipe that published the empty string here would have thrown the + # article away and reported success. + def answer_text(body) + choices = body['choices'] + unless choices.is_a?(Array) && choices.first.is_a?(Hash) + raise Error, 'Kimi answered without a choice' + end + + choice = choices.first + message = choice['message'] + raise Error, 'Kimi answered without a message' unless message.is_a?(Hash) + raise Error, "Kimi did not finish: #{choice['finish_reason']}" unless choice['finish_reason'] == 'stop' + + text = message['content'].to_s.strip + raise Error, 'Kimi answered with no content' if text.empty? + + text + end + + # The service's own explanation where it gave one, the status line + # otherwise. Neither carries the token, and the settings are never logged + # or raised wholesale. + def reason(response) + body = JSON.parse(response.body.to_s) + error = body['error'] + return error['message'].to_s if error.is_a?(Hash) && !error['message'].to_s.empty? + + response.message.to_s + rescue JSON::ParserError + response.message.to_s + end + end +end diff --git a/spec/plugins/filter/kimi_spec.rb b/spec/plugins/filter/kimi_spec.rb new file mode 100644 index 0000000..026ec12 --- /dev/null +++ b/spec/plugins/filter/kimi_spec.rb @@ -0,0 +1,306 @@ +# -*- coding: utf-8 -*- +# Name:: Automatic::Plugin::Filter::Kimi +# Author: id774 (More info: https://id774.net) +# Source Code:: https://github.com/id774/automaticruby +# License:: The GPL version 3, or LGPL version 3 (Dual License). +# Contact:: idnanashi@gmail.com +# Created:: Sep 11, 2026 +# Updated:: Sep 11, 2026 +# Copyright:: Copyright (c) 2012-2026 Automatic Ruby Developers. + +require File.expand_path(File.dirname(__FILE__) + '../../../spec_helper') + +require 'filter/kimi' +require 'json' +require 'net/http' + +# FilterKimi needs a Kimi API token; doc/PLUGINS.md section 6.3 classifies it +# as Supported (external). Everything up to the request is verified here -- +# what is posted where, with which authentication, and what each answer means +# -- and no example reaches the service. Only Kimi is exercised: the other AI +# filters have their own specs, and none of them stands in for another. +module KimiSpec + module_function + + # A real response object, because the plugin decides what to do from the + # class Net::HTTP would have given it. + def response(klass, code, body) + response = klass.new('1.1', code, 'Status') + response.instance_variable_set(:@body, body) + response.instance_variable_set(:@read, true) + response + end + + # A finished answer. `reasoning` is folded into the same message when given, + # exactly the way Kimi may send `reasoning_content` alongside `content` -- + # so a spec can prove it never leaks into the description. + def answered(text, reasoning: nil, finish_reason: 'stop') + message = { 'role' => 'assistant', 'content' => text } + message['reasoning_content'] = reasoning unless reasoning.nil? + + response(Net::HTTPOK, '200', JSON.generate( + 'object' => 'chat.completion', + 'choices' => [ + { 'index' => 0, 'message' => message, 'finish_reason' => finish_reason } + ] + )) + end + + def refused(klass, code, message) + response(klass, code, JSON.generate('error' => { 'message' => message })) + end +end + +describe Automatic::Plugin::FilterKimi do + let(:settings) { + { 'token' => 'test-token', 'model' => 'kimi-k3', 'prompt' => 'Summarize this.', + 'retry' => 1, 'interval' => 0 } + } + + let(:posted) { [] } + let(:connections) { [] } + + def one_item + AutomaticSpec.generate_pipeline { + feed { item 'https://example.com/a', 'A', 'the body of A' } + } + end + + # Stands in for the network: records the connection and the request, answers + # with what the example queued, and opens nothing. The default suite reaches + # no network (doc/POLICY.md Invariant 6). + def serve(*responses) + requests = posted + opened = connections + Net::HTTP.stub(:start) { |*args, &block| + opened << args + http = double('http') + http.stub(:request) { |request| + requests << request + responses.shift + } + block.call(http) + } + end + + def run(config, pipeline) + Automatic::Plugin::FilterKimi.new(config, pipeline).run + end + + describe 'the request it builds' do + before { + serve(KimiSpec.answered('a summary')) + run(settings, one_item) + } + + it "posts to Kimi's own endpoint, over verified TLS" do + connections[0][0].should == 'api.moonshot.ai' + connections[0][1].should == 443 + connections[0].last[:use_ssl].should == true + connections[0].last[:verify_mode].should == OpenSSL::SSL::VERIFY_PEER + posted[0].path.should == '/v1/chat/completions' + end + + it 'sends the token as a bearer token' do + posted[0]['authorization'].should == 'Bearer test-token' + posted[0]['content-type'].should == 'application/json' + end + + it 'sends only the model, the prompt as the system turn and the description as the user turn' do + JSON.parse(posted[0].body).should == { + 'model' => 'kimi-k3', + 'messages' => [ + { 'role' => 'system', 'content' => 'Summarize this.' }, + { 'role' => 'user', 'content' => 'the body of A' } + ] + } + end + end + + describe 'what it does to the pipeline' do + it 'replaces the description with the answer and leaves the rest alone' do + serve(KimiSpec.answered('a summary')) + returned = run(settings, one_item) + + returned.should have(1).feed + returned[0].items.should have(1).item + returned[0].items[0].description.should == 'a summary' + returned[0].items[0].title.should == 'A' + returned[0].items[0].link.should == 'https://example.com/a' + end + + it 'asks once for each item' do + serve(KimiSpec.answered('first summary'), KimiSpec.answered('second summary')) + returned = run(settings, AutomaticSpec.generate_pipeline { + feed { + item 'https://example.com/a', 'A', 'the body of A' + item 'https://example.com/b', 'B', 'the body of B' + } + }) + + posted.should have(2).requests + JSON.parse(posted[0].body)['messages'][1]['content'].should == 'the body of A' + JSON.parse(posted[1].body)['messages'][1]['content'].should == 'the body of B' + returned[0].items.map(&:description).should == ['first summary', 'second summary'] + end + + it 'ignores a feed that is nil' do + serve(KimiSpec.answered('a summary')) + run(settings, [nil] + one_item).should have(2).feeds + posted.should have(1).request + end + + it 'sends nothing for an item with no description, and empties none' do + serve + returned = run(settings, AutomaticSpec.generate_pipeline { + feed { + item 'https://example.com/a', 'A', '' + item 'https://example.com/b', 'B', ' ' + } + }) + + posted.should be_empty + returned[0].items.map(&:description).should == ['', ' '] + end + end + + describe 'when the request fails' do + it 'retries a server error and carries on' do + serve(KimiSpec.refused(Net::HTTPInternalServerError, '500', 'server error'), + KimiSpec.answered('a summary')) + returned = run(settings, one_item) + + posted.should have(2).requests + returned[0].items[0].description.should == 'a summary' + end + + it 'retries a rate limit' do + serve(KimiSpec.refused(Net::HTTPTooManyRequests, '429', 'too many requests'), + KimiSpec.answered('a summary')) + run(settings, one_item)[0].items[0].description.should == 'a summary' + posted.should have(2).requests + end + + it 'does not retry a network failure past the configured count' do + Net::HTTP.stub(:start) { raise SocketError, 'getaddrinfo failed' } + lambda { run(settings, one_item) }. + should raise_error(Automatic::Plugin::FilterKimi::Error, /gave up/) + end + + it 'gives up after the configured number of retries' do + serve(KimiSpec.refused(Net::HTTPInternalServerError, '500', 'server error'), + KimiSpec.refused(Net::HTTPInternalServerError, '500', 'server error')) + lambda { run(settings, one_item) }. + should raise_error(Automatic::Plugin::FilterKimi::Error, /gave up/) + posted.should have(2).requests + end + + it 'does not retry a rejected request' do + serve(KimiSpec.refused(Net::HTTPUnauthorized, '401', 'invalid token')) + lambda { run(settings, one_item) }. + should raise_error(Automatic::Plugin::FilterKimi::Error, /401/) + posted.should have(1).request + end + + it 'does not empty the description when the service fails' do + serve(KimiSpec.refused(Net::HTTPUnauthorized, '401', 'invalid token')) + pipeline = one_item + lambda { run(settings, pipeline) }.should raise_error(StandardError) + pipeline[0].items[0].description.should == 'the body of A' + end + end + + describe 'when the answer cannot be read' do + it 'raises on a body that is not JSON' do + serve(KimiSpec.response(Net::HTTPOK, '200', 'not json at all')) + lambda { run(settings, one_item) }. + should raise_error(Automatic::Plugin::FilterKimi::Error, /not JSON/) + posted.should have(1).request + end + + it 'raises on a body without the choice it expects' do + serve(KimiSpec.response(Net::HTTPOK, '200', JSON.generate('id' => 'cmpl_1'))) + lambda { run(settings, one_item) }. + should raise_error(Automatic::Plugin::FilterKimi::Error, /without a choice/) + end + + it 'raises rather than writing an empty description' do + serve(KimiSpec.answered('')) + pipeline = one_item + lambda { run(settings, pipeline) }. + should raise_error(Automatic::Plugin::FilterKimi::Error, /no content/) + pipeline[0].items[0].description.should == 'the body of A' + end + + it 'raises when the model has not finished, and does not empty the description' do + serve(KimiSpec.answered('a partial answer', finish_reason: 'length')) + pipeline = one_item + lambda { run(settings, pipeline) }. + should raise_error(Automatic::Plugin::FilterKimi::Error, /did not finish/) + pipeline[0].items[0].description.should == 'the body of A' + end + + it 'never lets reasoning_content reach the description' do + serve(KimiSpec.answered('a summary', reasoning: 'because the article said so')) + returned = run(settings, one_item) + + returned[0].items[0].description.should == 'a summary' + returned[0].items[0].description.should_not include('because the article said so') + end + end + + describe 'the settings it requires' do + it 'refuses a Recipe with no token' do + lambda { run(settings.merge('token' => nil), one_item) }. + should raise_error(ArgumentError, /token/) + end + + it 'refuses a Recipe with no model' do + lambda { run(settings.merge('model' => ''), one_item) }. + should raise_error(ArgumentError, /model/) + end + + it 'refuses a Recipe with no prompt' do + lambda { run(settings.merge('prompt' => nil), one_item) }. + should raise_error(ArgumentError, /prompt/) + end + + it 'asks nothing before it has what it needs' do + serve + lambda { run(settings.merge('prompt' => nil), one_item) }.should raise_error(ArgumentError) + posted.should be_empty + end + end + + describe 'the credential' do + # doc/PLUGINS.md section 3.7: never logged, never in an exception message, + # never written into an item. + it 'reaches neither the log nor the error, on the path that fails' do + messages = [] + logger = double('logger') + %i[info warn error].each { |level| logger.stub(level) { |message| messages << message.to_s } } + original = Automatic::Log.logger + + serve(KimiSpec.refused(Net::HTTPUnauthorized, '401', 'invalid token')) + begin + Automatic::Log.logger = logger + Automatic::Log.level('info') + lambda { run(settings, one_item) }.should raise_error(StandardError) { |error| + error.message.should_not include('test-token') + } + ensure + Automatic::Log.level('none') + Automatic::Log.logger = original + end + + messages.should_not be_empty + messages.each { |message| message.should_not include('test-token') } + end + + it 'writes nothing of itself into the item' do + serve(KimiSpec.answered('a summary')) + returned = run(settings, one_item) + returned[0].items[0].description.should_not include('test-token') + end + end +end